Merge pull request #48 from KGAudioLab/feat/2026-06-01-enhance-ai-agent
Feat/2026 06 01 enhance ai agent
This commit is contained in:
+22
-13
@@ -19,7 +19,7 @@
|
||||
|
||||
K.G.Studio est une DAW légère et moderne qui fonctionne entièrement dans le navigateur, avec **K.G.Studio Musician Assistant** en son cœur. Il propose une lecture réaliste des instruments via les samplers Tone.js, un éditeur piano roll, une gestion des pistes et des régions avec annulation/rétablissement complets, la persistance des projets dans OPFS (Origin Private File System), un panneau de configuration personnalisable, et un assistant IA intégré avec exécution d'outils.
|
||||
|
||||
**K.G.Studio Musician Assistant** est un agent d'assistance IA pour l'harmonie, l'arrangement et l'édition de notes, mais pas pour la composition entièrement automatique.
|
||||
**K.G.Studio Musician Assistant** est un co-créateur IA conscient du projet, conçu pour enrichir votre flux créatif. Plutôt que de générer directement des fichiers audio bruts, il opère au niveau structuré des pistes et des notes — vous aidant à composer des mélodies, construire des progressions harmoniques et éditer des notes MIDI, tout en vous laissant le contrôle total pour ajuster, affiner et perfectionner chaque détail.
|
||||
|
||||
<div align="center">
|
||||
<img src="./docs/KGOne-Demo-GIF.gif" alt="K.G.One Logo" width="640" />
|
||||
@@ -29,6 +29,15 @@ K.G.Studio est une DAW légère et moderne qui fonctionne entièrement dans le n
|
||||
|
||||
## Dernières mises à jour
|
||||
|
||||
- **2026.06.05** : développement majeur du **K.G.Studio Musician Assistant**, transformé en agent IA de niveau projet complet :
|
||||
- **Outils de gestion de pistes** — l'agent peut désormais lister, créer, mettre à jour et supprimer des pistes, et parcourir tous les instruments disponibles, sans nécessiter de sélectionner une région au préalable.
|
||||
- **Outils de pistes globales** — accès complet en lecture/écriture/suppression aux quatre pistes globales : **Chord Progression**, **Tempo (BPM)**, **Key Signature** et **Markers**. L'agent peut restructurer l'ensemble du squelette harmonique et rythmique d'un arrangement en une seule conversation.
|
||||
- **Confirmation des outils** — les opérations d'écriture affichent une étape de confirmation dans le chat avant d'être exécutées, vous donnant la possibilité de vérifier avant que quoi que ce soit ne change.
|
||||
- **Liste de tâches de l'agent** — l'assistant maintient désormais une liste de tâches en ligne, affichée sous forme de cartes de snapshot en direct directement dans le chat, rendant les plans en plusieurs étapes transparents et traçables.
|
||||
- **Historique des conversations** — les sessions de chat sont persistées par projet et peuvent être reprises après rechargement de la page.
|
||||
- **Compactage automatique du contexte** — les longues conversations sont résumées automatiquement lorsque la limite de contexte approche, permettant aux sessions de continuer sans intervention manuelle.
|
||||
- **Mode agent efficace** — un prompt simplifié et un ensemble d'outils réduit pour les modèles de langage plus petits ou locaux, activé automatiquement lors de l'utilisation du Local Browser LLM.
|
||||
|
||||
- **2026.05.30** : ajout de la **prise en charge de l'internationalisation (i18n)** — K.G.Studio est désormais disponible en quatre langues : **English**, **Simplified Chinese (简体中文)**, **Traditional Chinese (繁體中文)** et **Français**. La langue active se configure dans **Réglages ⚙️ → Général → Langue**, avec une option `Auto` qui détecte automatiquement la langue de votre navigateur.
|
||||
|
||||
- **2026.05.27** :
|
||||
@@ -49,13 +58,8 @@ K.G.Studio est une DAW légère et moderne qui fonctionne entièrement dans le n
|
||||
</div>
|
||||
|
||||
- **2026.05.02** : ajout de la **visualisation spectrogramme des pistes audio**. Les régions audio affichent désormais une superposition de spectrogramme en temps réel dans la grille des pistes. Ajout du **Piano Roll hybrid mode** : ouvrez le piano roll sur une région MIDI pendant que le spectrogramme d'une région audio voisine est affiché comme couche de référence, afin d'éditer les notes MIDI en fonction de la forme visuelle de l'audio. Ajout du **zoom avant/arrière dans le piano roll** avec conservation de la position de la vue pour qu'elle reste ancrée près de la tête de lecture actuelle. Ajout également du **réglage fin de la position des régions** avec petits incréments pour un placement précis. Enfin, ajout de la synchronisation du défilement de la tête de lecture entre composants afin que la grille principale et le piano roll restent synchronisés pendant la lecture.
|
||||
- **2026.04.29** : ajout de **Remix** et **Repaint** au panneau K.G.One Music Generator (alimenté par ACE-Step 1.5). **Remix** vous permet de refaire une région audio existante dans un nouveau style : sélectionnez une région audio, décrivez le style cible et fournissez éventuellement de nouvelles paroles, et ACE-Step réinterprétera le morceau avec l'instrumentation et l'ambiance demandées. **Repaint** vous permet de régénérer de façon chirurgicale une section précise d'un morceau : définissez une plage de boucle sur la timeline pour créer la fenêtre de repaint, puis décrivez le résultat souhaité pour cette section ; le reste du morceau reste inchangé. Les deux outils prennent en charge le même flux d'import que les autres onglets K.G.One : prévisualisation dans le lecteur intégré, glisser-déposer sur une piste, ou clic sur **Import Aligned to Source** pour placer automatiquement le résultat sous la région d'origine dans une nouvelle piste.
|
||||
- **2026.04.24** : ajout de l'intégration [**K.G.One Music Studio**](https://github.com/KGAudioLab/K.G.One) ! Lorsque K.G.Studio se connecte à un serveur K.G.One local, le panneau **K.G.One Music Generator** (bouton baguette magique ✦ dans la barre d'outils) devient disponible avec trois outils alimentés par l'IA : **Full Song Generation** (alimenté par ACE-Step 1.5 pour générer des chansons complètes à partir de prompts textuels), **Clip Generation** (alimenté par Foundation-1 pour générer des clips instrumentaux et des boucles MIDI à partir de texte), et **Stem Separation** (alimenté par python-audio-separator pour séparer n'importe quel audio en voix, instrumentaux, etc.). Les audios et MIDI générés peuvent être prévisualisés instantanément et glissés directement sur vos pistes. K.G.One fonctionne entièrement sur votre propre machine (Windows/Linux, GPU CUDA requis) ; consultez le [dépôt K.G.One](https://github.com/KGAudioLab/K.G.One) pour les instructions d'installation.
|
||||
- **2026.04.11** : migration du stockage des projets depuis IndexedDB vers OPFS (Origin Private File System) avec une structure basée sur des dossiers, afin de mieux gérer les fichiers médias. Ajout de la prise en charge des pistes audio avec import WAV/MP3, lecture, boucle et découpe non destructive des régions. Ajout également de l'export bounce vers WAV/MP3 via rendu hors ligne.
|
||||
- **2026.04.05** : migration de l'agent IA, passant d'un appel d'outils basé sur XML au function calling natif du SDK OpenAI afin d'améliorer la fiabilité et la compatibilité. Ajout de nouvelles options de modèles LLM, y compris la série GPT-5.4.
|
||||
- **2026.01.23** : implémentation de la lecture en boucle sans couture ! Faites glisser sur les numéros de mesure pour définir une plage de boucle, ou activez le mode boucle avec le bouton Loop dans la barre d'outils. La lecture en boucle utilise la fonctionnalité native de boucle de `Tone.js` pour une boucle précise au niveau de l'échantillon et sans coupure.
|
||||
- **2025.12.21** : implémentation de la prise en charge du clavier MIDI ! Vous pouvez désormais connecter un clavier MIDI et l'utiliser pour jouer des sons. Veuillez noter que cette fonction peut ne pas fonctionner de manière optimale dans Safari et certains autres navigateurs ne prenant pas entièrement en charge l'interface Web MIDI.
|
||||
- **2025.12.15** : ajout de l'assistant d'accords intelligent avec guidage harmonique fonctionnel (T/S/D). Survolez les touches du piano pour voir des suggestions d'accords adaptées au contexte et créer des accords complets en un clic !
|
||||
|
||||
Pour consulter l'historique complet des versions, voir les [**Notes de version**](./docs/RELEASE_NOTES.md).
|
||||
|
||||
## État du projet
|
||||
|
||||
@@ -120,7 +124,7 @@ K.G.Studio peut exécuter **Gemma 4 E4B** directement dans votre navigateur grâ
|
||||
- Dans **OpenAI Compatible Server → Base URL**, saisissez `https://openrouter.ai/api/v1`.
|
||||
|
||||
**Conseils :**
|
||||
- Vous pouvez également utiliser l'API officielle d'OpenAI, d'autres services compatibles OpenAI, ou un serveur LLM auto-hébergé (par exemple Ollama, vLLM). Notez que la qualité des modèles varie : tous les modèles ne sont pas aussi performants pour les tâches d'édition musicale. Pour l'hébergement local, nous recommandons `qwen3.5-35b-a3b` comme bon équilibre entre qualité de génération et exigences matérielles.
|
||||
- Vous pouvez également utiliser l'API officielle d'OpenAI, d'autres services compatibles OpenAI, ou un serveur LLM auto-hébergé (par exemple Ollama, vLLM). Notez que la qualité des modèles varie : tous les modèles ne sont pas aussi performants pour les tâches d'édition musicale. Pour l'auto-déploiement (nécessite ~24 Go de VRAM ou 24-32 Go de mémoire unifiée), nous recommandons : `qwen/qwen3.6-35b-a3b`, `google/gemma-4-26b-a4b-it` ou `google/gemma-4-31b-it`.
|
||||
- Si vous disposez d'un abonnement actif chez OpenAI ou chez un autre fournisseur LLM, vous pouvez utiliser [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) pour exécuter un serveur proxy local qui achemine les requêtes via votre abonnement existant, sans nécessiter de clé API séparée.
|
||||
|
||||
### Opérations DAW de base
|
||||
@@ -277,7 +281,7 @@ Remarque : en raison des limitations CORS chez certains fournisseurs, Google Gem
|
||||
1. Obtenez une clé API OpenAI auprès de [**OpenAI**](https://platform.openai.com/account/api-keys). Vous devrez peut-être créer un compte et ajouter un moyen de paiement pour générer une clé API.
|
||||
2. Dans **Settings ⚙️ → General → LLM Provider**, sélectionnez **OpenAI** comme fournisseur.
|
||||
3. Saisissez votre clé API dans **OpenAI → Key**.
|
||||
4. Sélectionnez votre modèle préféré dans la liste **OpenAI → Model**. Pour un bon équilibre entre performances et coût, nous recommandons `gpt-5.4-mini`.
|
||||
4. Sélectionnez votre modèle préféré dans la liste **OpenAI → Model**. Pour un bon équilibre entre performances et coût, nous recommandons `gpt-5.4`.
|
||||
5. Vous pouvez également choisir d'activer Flex Mode dans **OpenAI → Flex Mode**. Flex Mode offre un tarif réduit, mais peut entraîner des temps de réponse plus lents ou des erreurs côté serveur.
|
||||
|
||||
### Utiliser OpenRouter
|
||||
@@ -291,9 +295,14 @@ OpenRouter est une plateforme qui fournit un accès unifié à un large éventai
|
||||
**Remarque :** chaque fournisseur de modèle peut avoir des politiques différentes en matière de conservation des données et de confidentialité. Veuillez les consulter avant utilisation.
|
||||
5. Saisissez le nom du modèle choisi dans **OpenAI Compatible Server → Model**. Les séries recommandées comprennent :
|
||||
- `Anthropic: Claude Sonnet 4.6` (`anthropic/claude-sonnet-4.6`: [Link](https://openrouter.ai/anthropic/claude-sonnet-4.6)) — meilleur équilibre qualité/coût pour la série Claude
|
||||
- `Qwen: Qwen3.5-35B-A3B` (`qwen/qwen3.5-35b-a3b`: [Link](https://openrouter.ai/qwen/qwen3.5-35b-a3b)) — modèle open source recommandé
|
||||
- `Qwen: Qwen3-Next-80B-A3B` (MODÈLE GRATUIT : `qwen/qwen3-next-80b-a3b-instruct:free`: [Link](https://openrouter.ai/qwen/qwen3-next-80b-a3b-instruct:free)) — modèle gratuit recommandé
|
||||
- `OpenAI: GPT-OSS 120B` (MODÈLE GRATUIT : `openai/gpt-oss-120b:free`: [Link](https://openrouter.ai/openai/gpt-oss-120b:free)) — modèle gratuit recommandé
|
||||
- Modèles gratuits :
|
||||
- `OpenAI: GPT-OSS 120B` (MODÈLE GRATUIT : `openai/gpt-oss-120b:free`: [Link](https://openrouter.ai/openai/gpt-oss-120b:free))
|
||||
- `Google: Gemma 4 26B A4B IT` (MODÈLE GRATUIT : `google/gemma-4-26b-a4b-it:free`: [Link](https://openrouter.ai/google/gemma-4-26b-a4b-it:free))
|
||||
- `Google: Gemma 4 31B IT` (MODÈLE GRATUIT : `google/gemma-4-31b-it:free`: [Link](https://openrouter.ai/google/gemma-4-31b-it:free))
|
||||
- Pour l'auto-déploiement (nécessite ~24 Go de VRAM ou 24-32 Go de mémoire unifiée), nous recommandons :
|
||||
- `Qwen: Qwen3.6 35B A3B` (`qwen/qwen3.6-35b-a3b`: [Link](https://openrouter.ai/qwen/qwen3.6-35b-a3b))
|
||||
- `Google: Gemma 4 26B A4B IT` (`google/gemma-4-26b-a4b-it`: [Link](https://openrouter.ai/google/gemma-4-26b-a4b-it))
|
||||
- `Google: Gemma 4 31B IT` (`google/gemma-4-31b-it`: [Link](https://openrouter.ai/google/gemma-4-31b-it))
|
||||
- Remarque : les fournisseurs de modèles gratuits peuvent collecter vos données ; consultez la page du modèle avant utilisation
|
||||
- Remarque : la disponibilité des modèles gratuits change fréquemment ; pour les options gratuites les plus récentes, consultez la [page des modèles OpenRouter](https://openrouter.ai/models) et utilisez le filtre **Prompt Pricing**
|
||||
6. Saisissez l'URL de base `https://openrouter.ai/api/v1` dans **OpenAI Compatible Server → Base URL**.
|
||||
|
||||
+22
-13
@@ -19,7 +19,7 @@
|
||||
|
||||
K.G.Studio 是一款轻量、现代化的 DAW,完全运行于浏览器中,并以 **K.G.Studio 音乐创作助手** 为核心。它提供基于 Tone.js sampler 的真实乐器回放、钢琴卷帘编辑器、支持完整撤销/重做的音轨与区域管理、基于 OPFS(Origin Private File System)的项目持久化、可配置的设置面板,以及可执行工具的内置 AI 助手。
|
||||
|
||||
**K.G.Studio 音乐创作助手** 是一个面向和声、编曲与音符编辑的 AI 助手,但并不负责整首作品的全自动作曲。
|
||||
**K.G.Studio 音乐创作助手** 是一款具备项目感知能力的 AI 协同创作助手。它并不直接生成音频文件(如 WAV 格式),而是直接在结构化的音轨和音符层级进行操作——帮助您编写旋律、构建和弦进行以及编辑 MIDI 音符,同时将完整的控制权留给您,方便您后续轻松调整、微调和雕琢每一个音乐细节。
|
||||
|
||||
<div align="center">
|
||||
<img src="./docs/KGOne-Demo-GIF.gif" alt="K.G.One Logo" width="640" />
|
||||
@@ -29,6 +29,15 @@ K.G.Studio 是一款轻量、现代化的 DAW,完全运行于浏览器中,
|
||||
|
||||
## 最新更新
|
||||
|
||||
- **2026.06.05**: 大幅扩展 **K.G.Studio 音乐创作助手**,升级为完整的项目级 AI Agent:
|
||||
- **音轨管理工具** — Agent 现在无需选择区域,即可列出、创建、更新和删除音轨,以及浏览所有可用乐器。
|
||||
- **全局轨工具** — 完整读取/写入/删除四条全局轨:**和弦进行**、**速度(BPM)**、**调号** 和 **Marker**。Agent 可在一次对话中重构整首编曲的和声与节奏骨架。
|
||||
- **工具确认机制** — 写操作在执行前会在聊天中显示确认步骤,让您在修改生效前有机会审查。
|
||||
- **Agent 待办清单** — 助手现在会在聊天中维护内联任务清单,以实时快照卡片的形式呈现,让多步骤计划一目了然。
|
||||
- **对话历史** — 聊天会话会按项目持久化保存,并可跨页面刷新恢复。
|
||||
- **自动上下文压缩** — 当对话接近上下文限制时,旧消息会自动摘要压缩,保证长会话持续运转。
|
||||
- **高效 Agent 模式** — 为较小/本地语言模型提供精简提示词与工具集,使用 Local Browser LLM 时自动启用。
|
||||
|
||||
- **2026.05.30**: 新增 **国际化(i18n)支持** — K.G.Studio 现已提供四种语言版本:**English**、**简体中文**、**繁體中文** 和 **Français**。可在 **设置 ⚙️ → 通用 → 语言** 中配置首选语言,选择 `Auto` 时将自动检测浏览器语言。
|
||||
|
||||
- **2026.05.27**:
|
||||
@@ -49,13 +58,8 @@ K.G.Studio 是一款轻量、现代化的 DAW,完全运行于浏览器中,
|
||||
</div>
|
||||
|
||||
- **2026.05.02**: 新增 **音频轨频谱可视化**。音频区域现在会在时间网格中显示实时频谱叠加层。另新增 **Piano Roll hybrid mode**:当您在 MIDI 区域中打开钢琴卷帘时,可将相邻音频区域的频谱作为参考层显示,从而参照音频形状编辑 MIDI 音符。新增 **钢琴卷帘缩放**,并保留当前视口位置,使画面始终锚定在当前播放头附近。还新增 **区域微调位置**,可用小步长推动区域以实现精确摆放;同时新增跨组件的播放头滚动同步,使主网格与钢琴卷帘在播放期间保持联动。
|
||||
- **2026.04.29**: 在 K.G.One Music Generator 面板中新增 **Remix** 和 **Repaint**(由 ACE-Step 1.5 驱动)。**Remix** 可让您用新风格重制现有音频区域,您只需选中音频区域、描述目标风格,并可选填写新歌词,ACE-Step 就会按提示重新演绎歌曲的配器与氛围。**Repaint** 可对歌曲中特定片段进行局部再生成,您可以先在时间线上设置 loop 范围作为重绘窗口,再描述该片段应有的声音效果,其余部分将保持不变。两项功能都支持与其他 K.G.One 标签页相同的导入流程:可在内置播放器中预览、拖拽到音轨上,或点击 **Import Aligned to Source**,自动将结果放置到原始区域下方的新音轨中。
|
||||
- **2026.04.24**: 新增 [**K.G.One Music Studio**](https://github.com/KGAudioLab/K.G.One) 集成。当 K.G.Studio 连接到本地 K.G.One 服务器后,**K.G.One Music Generator** 面板(工具栏中的魔杖按钮 ✦)即可启用,其中包含三项 AI 驱动工具:**Full Song Generation**(由 ACE-Step 1.5 驱动,可根据文本提示生成整首歌曲)、**Clip Generation**(由 Foundation-1 驱动,可根据文本生成乐器片段与 MIDI loop),以及 **Stem Separation**(由 python-audio-separator 驱动,可将任意音频拆分为人声、伴奏等多个 stem)。生成的音频和 MIDI 均可即时预览,并可直接拖拽到您的音轨中。K.G.One 完全运行在您自己的机器上(Windows/Linux,需要 CUDA GPU);详细设置说明请参阅 [K.G.One 仓库](https://github.com/KGAudioLab/K.G.One)。
|
||||
- **2026.04.11**: 将项目存储从 IndexedDB 迁移到 OPFS(Origin Private File System),并采用基于文件夹的结构,以更好地处理媒体文件。新增音频轨支持,包括 WAV/MP3 导入、回放、循环,以及非破坏性区域裁剪。另新增基于离线渲染的 WAV/MP3 导出功能。
|
||||
- **2026.04.05**: 将 AI agent 从基于 XML 的工具调用迁移到原生 OpenAI SDK function calling,以提升可靠性与兼容性。另新增 GPT-5.4 系列等 LLM 模型选项。
|
||||
- **2026.01.23**: 实现无缝循环播放。您可以拖动小节编号设置 loop 范围,或通过工具栏中的 Loop 按钮切换循环模式。循环播放基于 `Tone.js` 的原生 loop 机制,可实现采样级精确、无缝衔接的循环。
|
||||
- **2025.12.21**: 实现 MIDI 键盘支持。您现在可以连接 MIDI 键盘并直接用其演奏声音。请注意,由于 Safari 和部分其他浏览器对 Web MIDI 接口支持不完整,此功能在这些浏览器中可能无法达到最佳效果。
|
||||
- **2025.12.15**: 新增智能和弦助手,支持功能和声指导(T/S/D)。将鼠标悬停在琴键上即可查看上下文相关的和弦建议,并可一键创建完整和弦。
|
||||
|
||||
查看完整版本历史,请参阅 [**发布说明**](./docs/RELEASE_NOTES.md)。
|
||||
|
||||
## 项目状态
|
||||
|
||||
@@ -120,7 +124,7 @@ K.G.Studio 可以借助 WebGPU 加速,在浏览器中直接运行 **Gemma 4 E4
|
||||
- 在 **OpenAI Compatible Server → 基础 URL** 中输入 `https://openrouter.ai/api/v1`。
|
||||
|
||||
**提示:**
|
||||
- 您也可以使用官方 OpenAI API、其他 OpenAI 兼容服务,或自托管 LLM 服务器(如 Ollama、vLLM)。请注意,不同模型的质量差异较大,并非所有模型都同样适合音乐编辑任务。对于本地部署,我们推荐 `qwen3.5-35b-a3b`,它在生成质量与硬件需求之间取得了较好的平衡。
|
||||
- 您也可以使用官方 OpenAI API、其他 OpenAI 兼容服务,或自托管 LLM 服务器(如 Ollama、vLLM)。请注意,不同模型的质量差异较大,并非所有模型都同样适合音乐编辑任务。对于自托管/本地部署(需要约 24G 显存或 24-32GB 统一内存),我们推荐:`qwen/qwen3.6-35b-a3b`、`google/gemma-4-26b-a4b-it` 或 `google/gemma-4-31b-it`。
|
||||
- 如果您已订阅 OpenAI 或其他 LLM 提供方,可以使用 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 运行一个本地代理服务器,通过现有订阅转发请求,而无需单独准备 API Key。
|
||||
|
||||
### 基本 DAW 操作
|
||||
@@ -279,7 +283,7 @@ K.G.Studio 会从 `./public/config.json` 加载默认配置(内部也提供回
|
||||
1. 在 [**OpenAI**](https://platform.openai.com/account/api-keys) 获取 OpenAI API Key。您可能需要先注册账号并添加支付方式,才能生成 API Key。
|
||||
2. 在 **设置 ⚙️ → 通用 → LLM 提供方** 中选择 **OpenAI** 作为提供方。
|
||||
3. 在 **OpenAI → 密钥** 中输入您的 API Key。
|
||||
4. 在 **OpenAI → 模型** 下拉中选择您偏好的模型。若希望在性能与成本之间取得较好平衡,我们推荐 `gpt-5.4-mini`。
|
||||
4. 在 **OpenAI → 模型** 下拉中选择您偏好的模型。若希望在性能与成本之间取得较好平衡,我们推荐 `gpt-5.4`。
|
||||
5. 您也可以选择是否在 **OpenAI → Flex 模式** 中启用 Flex Mode。Flex Mode 可以降低价格,但也可能带来更慢的响应时间或更多服务端错误。
|
||||
|
||||
### 使用 OpenRouter
|
||||
@@ -293,9 +297,14 @@ OpenRouter 是一个统一接入平台,可让您访问来自多个提供方的
|
||||
**注意:** 不同模型提供方的数据保留与隐私政策可能不同,请在使用前自行查看。
|
||||
5. 在 **OpenAI Compatible Server → 模型** 中输入您所选的模型名。推荐系列包括:
|
||||
- `Anthropic: Claude Sonnet 4.6` (`anthropic/claude-sonnet-4.6`: [Link](https://openrouter.ai/anthropic/claude-sonnet-4.6)) — Claude 系列中质量与成本平衡较好的选择
|
||||
- `Qwen: Qwen3.5-35B-A3B` (`qwen/qwen3.5-35b-a3b`: [Link](https://openrouter.ai/qwen/qwen3.5-35b-a3b)) — 推荐的开源模型
|
||||
- `Qwen: Qwen3-Next-80B-A3B`(免费模型:`qwen/qwen3-next-80b-a3b-instruct:free`: [Link](https://openrouter.ai/qwen/qwen3-next-80b-a3b-instruct:free))— 推荐的免费模型
|
||||
- `OpenAI: GPT-OSS 120B`(免费模型:`openai/gpt-oss-120b:free`: [Link](https://openrouter.ai/openai/gpt-oss-120b:free))— 推荐的免费模型
|
||||
- 免费模型:
|
||||
- `OpenAI: GPT-OSS 120B`(免费模型:`openai/gpt-oss-120b:free`: [Link](https://openrouter.ai/openai/gpt-oss-120b:free))
|
||||
- `Google: Gemma 4 26B A4B IT`(免费模型:`google/gemma-4-26b-a4b-it:free`: [Link](https://openrouter.ai/google/gemma-4-26b-a4b-it:free))
|
||||
- `Google: Gemma 4 31B IT`(免费模型:`google/gemma-4-31b-it:free`: [Link](https://openrouter.ai/google/gemma-4-31b-it:free))
|
||||
- 对于自托管/本地部署(需要约 24G 显存或 24-32GB 统一内存),我们推荐:
|
||||
- `Qwen: Qwen3.6 35B A3B` (`qwen/qwen3.6-35b-a3b`: [Link](https://openrouter.ai/qwen/qwen3.6-35b-a3b))
|
||||
- `Google: Gemma 4 26B A4B IT` (`google/gemma-4-26b-a4b-it`: [Link](https://openrouter.ai/google/gemma-4-26b-a4b-it))
|
||||
- `Google: Gemma 4 31B IT` (`google/gemma-4-31b-it`: [Link](https://openrouter.ai/google/gemma-4-31b-it))
|
||||
- 注意:免费模型提供方可能会收集您的数据,使用前请先查看模型页面说明
|
||||
- 注意:免费模型的可用性变化频繁。如需查看最新免费选项,请访问 [OpenRouter Models Page](https://openrouter.ai/models),并使用 **Prompt Pricing** 过滤当前免费模型
|
||||
6. 在 **OpenAI Compatible Server → 基础 URL** 中填写 `https://openrouter.ai/api/v1`。
|
||||
|
||||
+22
-13
@@ -19,7 +19,7 @@
|
||||
|
||||
K.G.Studio 是一款輕量、現代化的 DAW,完全執行於瀏覽器中,並以 **K.G.Studio 音樂創作助手** 為核心。它提供基於 Tone.js sampler 的真實樂器回放、Piano Roll 編輯器、支援完整復原/重做的音軌與區域管理、基於 OPFS(Origin Private File System)的專案持久化、可設定的設定面板,以及可執行工具的內建 AI 助手。
|
||||
|
||||
**K.G.Studio 音樂創作助手** 是一個面向和聲、編曲與音符編輯的 AI 助手,但並不負責整首作品的全自動作曲。
|
||||
**K.G.Studio 音樂創作助手** 是一款具備專案感知能力的 AI 協同創作助手。它並不直接生成音訊檔案(如 WAV 格式),而是直接在結構化的音軌和音符層級進行操作——幫助您編寫旋律、構建和聲進行以及編輯 MIDI 音符,同時將完整的控制權留給您,方便您後續輕鬆調整、微調和雕琢每一個音樂細節。
|
||||
|
||||
<div align="center">
|
||||
<img src="./docs/KGOne-Demo-GIF.gif" alt="K.G.One Logo" width="640" />
|
||||
@@ -29,6 +29,15 @@ K.G.Studio 是一款輕量、現代化的 DAW,完全執行於瀏覽器中,
|
||||
|
||||
## 最新更新
|
||||
|
||||
- **2026.06.05**: 大幅擴展 **K.G.Studio 音樂創作助手**,升級為完整的專案級 AI Agent:
|
||||
- **音軌管理工具** — Agent 現在無需選取區域,即可列出、建立、更新和刪除音軌,以及瀏覽所有可用樂器。
|
||||
- **全域軌工具** — 完整讀取/寫入/刪除四條全域軌:**和弦進行**、**速度(BPM)**、**調號** 和 **Marker**。Agent 可在一次對話中重構整首編曲的和聲與節奏骨架。
|
||||
- **工具確認機制** — 寫操作在執行前會在聊天中顯示確認步驟,讓您在修改生效前有機會審查。
|
||||
- **Agent 待辦清單** — 助手現在會在聊天中維護內聯任務清單,以即時快照卡片的形式呈現,讓多步驟計劃一目了然。
|
||||
- **對話歷史** — 聊天會話會按專案持久化保存,並可跨頁面重新整理恢復。
|
||||
- **自動上下文壓縮** — 當對話接近上下文限制時,舊訊息會自動摘要壓縮,保證長對話持續運作。
|
||||
- **高效 Agent 模式** — 為較小/本地語言模型提供精簡提示詞與工具集,使用 Local Browser LLM 時自動啟用。
|
||||
|
||||
- **2026.05.30**: 新增 **國際化(i18n)支援** — K.G.Studio 現已提供四種語言版本:**English**、**简体中文**、**繁體中文** 和 **Français**。可在 **設定 ⚙️ → 通用 → 語言** 中設定偏好語言,選擇 `Auto` 時將自動偵測瀏覽器語言。
|
||||
|
||||
- **2026.05.27**:
|
||||
@@ -49,13 +58,8 @@ K.G.Studio 是一款輕量、現代化的 DAW,完全執行於瀏覽器中,
|
||||
</div>
|
||||
|
||||
- **2026.05.02**: 新增 **音訊軌頻譜可視化**。音訊區域現在會在時間網格中顯示即時頻譜疊加層。另新增 **Piano Roll hybrid mode**:當您在 MIDI 區域中開啟鋼琴卷簾時,可將相鄰音訊區域的頻譜作為參考層顯示,從而參照音訊形狀編輯 MIDI 音符。新增 **鋼琴卷簾縮放**,並保留目前視口位置,使畫面始終錨定在目前播放頭附近。還新增 **區域微調位置**,可用小步長推動區域以實現精確擺放;同時新增跨元件的播放頭捲動同步,使主網格與鋼琴卷簾在播放期間保持連動。
|
||||
- **2026.04.29**: 在 K.G.One Music Generator 面板中新增 **Remix** 和 **Repaint**(由 ACE-Step 1.5 驅動)。**Remix** 可讓您用新風格重製現有音訊區域,您只需選取音訊區域、描述目標風格,並可選填寫新歌詞,ACE-Step 就會按提示重新演繹歌曲的配器與氛圍。**Repaint** 可對歌曲中特定片段進行局部再生成,您可以先在時間線上設定 loop 範圍作為重繪視窗,再描述該片段應有的聲音效果,其餘部分將保持不變。兩項功能都支援與其他 K.G.One 分頁相同的匯入流程:可在內建播放器中預覽、拖曳到音軌上,或點擊 **Import Aligned to Source**,自動將結果放置到原始區域下方的新音軌中。
|
||||
- **2026.04.24**: 新增 [**K.G.One Music Studio**](https://github.com/KGAudioLab/K.G.One) 整合。當 K.G.Studio 連線到本地 K.G.One 伺服器後,**K.G.One Music Generator** 面板(工具列中的魔杖按鈕 ✦)即可啟用,其中包含三項 AI 驅動工具:**Full Song Generation**(由 ACE-Step 1.5 驅動,可根據文字提示生成整首歌曲)、**Clip Generation**(由 Foundation-1 驅動,可根據文字生成樂器片段與 MIDI loop),以及 **Stem Separation**(由 python-audio-separator 驅動,可將任意音訊拆分為人聲、伴奏等多個 stem)。生成的音訊和 MIDI 均可即時預覽,並可直接拖曳到您的音軌中。K.G.One 完全執行在您自己的機器上(Windows/Linux,需要 CUDA GPU);詳細設定說明請參閱 [K.G.One 倉庫](https://github.com/KGAudioLab/K.G.One)。
|
||||
- **2026.04.11**: 將專案儲存從 IndexedDB 遷移到 OPFS(Origin Private File System),並採用基於資料夾的結構,以更好地處理媒體檔案。新增音訊軌支援,包括 WAV/MP3 匯入、回放、循環,以及非破壞性區域裁剪。另新增基於離線渲染的 WAV/MP3 匯出功能。
|
||||
- **2026.04.05**: 將 AI agent 從基於 XML 的工具呼叫遷移到原生 OpenAI SDK function calling,以提升可靠性與相容性。另新增 GPT-5.4 系列等 LLM 模型選項。
|
||||
- **2026.01.23**: 實現無縫循環播放。您可以拖動小節編號設定 loop 範圍,或透過工具列中的 Loop 按鈕切換循環模式。循環播放基於 `Tone.js` 的原生 loop 機制,可實現取樣級精確、無縫銜接的循環。
|
||||
- **2025.12.21**: 實現 MIDI 鍵盤支援。您現在可以連接 MIDI 鍵盤並直接用其演奏聲音。請注意,由於 Safari 和部分其他瀏覽器對 Web MIDI 介面支援不完整,此功能在這些瀏覽器中可能無法達到最佳效果。
|
||||
- **2025.12.15**: 新增智慧和弦助手,支援功能和聲指導(T/S/D)。將滑鼠懸停在琴鍵上即可查看與上下文相關的和弦建議,並可一鍵建立完整和弦。
|
||||
|
||||
查看完整版本歷史,請參閱 [**發佈說明**](./docs/RELEASE_NOTES.md)。
|
||||
|
||||
## 專案狀態
|
||||
|
||||
@@ -120,7 +124,7 @@ K.G.Studio 可以藉助 WebGPU 加速,在瀏覽器中直接執行 **Gemma 4 E4
|
||||
- 在 **OpenAI Compatible Server → 基礎 URL** 中輸入 `https://openrouter.ai/api/v1`。
|
||||
|
||||
**提示:**
|
||||
- 您也可以使用官方 OpenAI API、其他 OpenAI 相容服務,或自行託管的 LLM 伺服器(如 Ollama、vLLM)。請注意,不同模型的品質差異較大,並非所有模型都同樣適合音樂編輯任務。對於本地部署,我們推薦 `qwen3.5-35b-a3b`,它在生成品質與硬體需求之間取得了較好的平衡。
|
||||
- 您也可以使用官方 OpenAI API、其他 OpenAI 相容服務,或自行託管的 LLM 伺服器(如 Ollama、vLLM)。請注意,不同模型的品質差異較大,並非所有模型都同樣適合音樂編輯任務。對於自託管/本地部署(需要約 24G 顯存或 24-32GB 統一記憶體),我們推薦:`qwen/qwen3.6-35b-a3b`、`google/gemma-4-26b-a4b-it` 或 `google/gemma-4-31b-it`。
|
||||
- 如果您已訂閱 OpenAI 或其他 LLM 提供方,可以使用 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) 執行一個本地代理伺服器,透過現有訂閱轉發請求,而無需另外準備 API Key。
|
||||
|
||||
### 基本 DAW 操作
|
||||
@@ -277,7 +281,7 @@ K.G.Studio 會從 `./public/config.json` 載入預設設定(內部也提供回
|
||||
1. 在 [**OpenAI**](https://platform.openai.com/account/api-keys) 取得 OpenAI API Key。您可能需要先註冊帳號並新增付款方式,才能生成 API Key。
|
||||
2. 在 **設定 ⚙️ → 通用 → LLM 提供方** 中選擇 **OpenAI** 作為提供方。
|
||||
3. 在 **OpenAI → 密鑰** 中輸入您的 API Key。
|
||||
4. 在 **OpenAI → 模型** 下拉中選擇您偏好的模型。若希望在效能與成本之間取得較好平衡,我們推薦 `gpt-5.4-mini`。
|
||||
4. 在 **OpenAI → 模型** 下拉中選擇您偏好的模型。若希望在效能與成本之間取得較好平衡,我們推薦 `gpt-5.4`。
|
||||
5. 您也可以選擇是否在 **OpenAI → Flex 模式** 中啟用 Flex Mode。Flex Mode 可以降低價格,但也可能帶來更慢的回應時間或更多伺服器端錯誤。
|
||||
|
||||
### 使用 OpenRouter
|
||||
@@ -291,9 +295,14 @@ OpenRouter 是一個統一接入平台,可讓您存取來自多個提供方的
|
||||
**注意:** 不同模型提供方的資料保留與隱私政策可能不同,請在使用前自行查看。
|
||||
5. 在 **OpenAI Compatible Server → 模型** 中輸入您所選的模型名。推薦系列包括:
|
||||
- `Anthropic: Claude Sonnet 4.6` (`anthropic/claude-sonnet-4.6`: [Link](https://openrouter.ai/anthropic/claude-sonnet-4.6)) — Claude 系列中品質與成本平衡較好的選擇
|
||||
- `Qwen: Qwen3.5-35B-A3B` (`qwen/qwen3.5-35b-a3b`: [Link](https://openrouter.ai/qwen/qwen3.5-35b-a3b)) — 推薦的開源模型
|
||||
- `Qwen: Qwen3-Next-80B-A3B`(免費模型:`qwen/qwen3-next-80b-a3b-instruct:free`: [Link](https://openrouter.ai/qwen/qwen3-next-80b-a3b-instruct:free))— 推薦的免費模型
|
||||
- `OpenAI: GPT-OSS 120B`(免費模型:`openai/gpt-oss-120b:free`: [Link](https://openrouter.ai/openai/gpt-oss-120b:free))— 推薦的免費模型
|
||||
- 免費模型:
|
||||
- `OpenAI: GPT-OSS 120B`(免費模型:`openai/gpt-oss-120b:free`: [Link](https://openrouter.ai/openai/gpt-oss-120b:free))
|
||||
- `Google: Gemma 4 26B A4B IT`(免費模型:`google/gemma-4-26b-a4b-it:free`: [Link](https://openrouter.ai/google/gemma-4-26b-a4b-it:free))
|
||||
- `Google: Gemma 4 31B IT`(免費模型:`google/gemma-4-31b-it:free`: [Link](https://openrouter.ai/google/gemma-4-31b-it:free))
|
||||
- 對於自託管/本地部署(需要約 24G 顯存或 24-32GB 統一記憶體),我們推薦:
|
||||
- `Qwen: Qwen3.6 35B A3B` (`qwen/qwen3.6-35b-a3b`: [Link](https://openrouter.ai/qwen/qwen3.6-35b-a3b))
|
||||
- `Google: Gemma 4 26B A4B IT` (`google/gemma-4-26b-a4b-it`: [Link](https://openrouter.ai/google/gemma-4-26b-a4b-it))
|
||||
- `Google: Gemma 4 31B IT` (`google/gemma-4-31b-it`: [Link](https://openrouter.ai/google/gemma-4-31b-it))
|
||||
- 注意:免費模型提供方可能會蒐集您的資料,使用前請先查看模型頁面說明
|
||||
- 注意:免費模型的可用性變化頻繁。如需查看最新免費選項,請造訪 [OpenRouter Models Page](https://openrouter.ai/models),並使用 **Prompt Pricing** 過濾目前免費模型
|
||||
6. 在 **OpenAI Compatible Server → 基礎 URL** 中填入 `https://openrouter.ai/api/v1`。
|
||||
|
||||
@@ -19,7 +19,7 @@ English | [Français](./README-fr.md) | [简体中文](./README-zh_cn.md) | [繁
|
||||
|
||||
K.G.Studio is a lightweight, modern DAW that runs entirely in the browser with **K.G.Studio Musician Assistant** at its core. It features realistic instrument playback via Tone.js samplers, a piano‑roll editor, track and region management with full undo/redo, project persistence to OPFS (Origin Private File System), a configurable settings panel, and an integrated AI assistant with tool execution.
|
||||
|
||||
**K.G.Studio Musician Assistant** is an AI assistance agent for harmony, arrangement, and note editing — but not full auto‑composition.
|
||||
**K.G.Studio Musician Assistant** is a project-aware AI co-creator designed to elevate your creative workflow. Rather than generating raw audio files, it operates directly at the structured track and note level—helping you draft melodies, build harmonic progressions, and edit MIDI notes, while leaving you in complete control to adjust, fine-tune, and perfect every single detail.
|
||||
|
||||
<div align="center">
|
||||
<img src="./docs/KGOne-Demo-GIF.gif" alt="K.G.One Logo" width="640" />
|
||||
@@ -29,6 +29,15 @@ K.G.Studio is a lightweight, modern DAW that runs entirely in the browser with *
|
||||
|
||||
## Latest Updates
|
||||
|
||||
- **2026.06.05**: Significantly expanded the **K.G.Studio Musician Assistant** into a full project-level agent:
|
||||
- **Track management tools** — the agent can now list, create, update, and delete tracks, and browse all available instruments, without requiring a region to be selected first.
|
||||
- **Global track tools** — full read/write/remove access to all four global tracks: **Chord Progression**, **Tempo (BPM)**, **Key Signature**, and **Markers**. The agent can restructure an entire arrangement's harmonic and rhythmic skeleton in a single conversation.
|
||||
- **Tool confirmation** — write operations surface a confirmation step in the chat before executing, giving you a chance to review before anything changes.
|
||||
- **Agent todo list** — the assistant now maintains an inline task checklist rendered as live snapshot cards directly in the chat, making multi-step plans transparent and trackable.
|
||||
- **Conversation history** — chat sessions are persisted per project and can be resumed across page reloads.
|
||||
- **Automatic context compaction** — long conversations are summarised automatically when the context limit approaches, keeping sessions running without manual intervention.
|
||||
- **Efficient agent mode** — a streamlined prompt and reduced tool set for smaller / local language models, activated automatically when using the Local Browser LLM.
|
||||
|
||||
- **2026.05.30**: Added **internationalization (i18n) support** — K.G.Studio now ships in four languages: **English**, **Simplified Chinese (简体中文)**, **Traditional Chinese (繁體中文)**, and **French (Français)**. The active language can be configured under **Settings ⚙️ → General → Language**, with an `Auto` option that automatically detects your browser's locale.
|
||||
|
||||
- **2026.05.27**:
|
||||
@@ -49,13 +58,8 @@ K.G.Studio is a lightweight, modern DAW that runs entirely in the browser with *
|
||||
</div>
|
||||
|
||||
- **2026.05.02**: Added **audio track spectrogram visualization** — audio regions now display a real-time spectrogram overlay in the track grid. Added **Piano Roll hybrid mode**: open the piano roll on a MIDI region while an adjacent audio region's spectrogram is shown as a reference layer, letting you edit MIDI notes against the visual shape of the audio. Added **piano roll zoom in/out** with viewport-position preservation so the view stays anchored to the current playhead. Added **fine-tune region position**: nudge regions by small increments for precise placement. Also added cross-component playhead scroll synchronization so the main grid and piano roll stay in sync during playback.
|
||||
- **2026.04.29**: Added **Remix** and **Repaint** to the K.G.One Music Generator panel (powered by ACE-Step 1.5). **Remix** lets you cover an existing audio region in a new style — select an audio region, describe the target style and optionally provide new lyrics, and ACE-Step will re-perform the song with the prompted instrumentation and feel. **Repaint** lets you surgically re-generate a specific section of a song — set a loop range on the timeline to define the repaint window, then describe what you want that section to sound like; the rest of the song stays untouched. Both tools support the same import workflow as the other K.G.One tabs: preview the result in the built-in player, drag it onto a track, or click **Import Aligned to Source** to automatically place it below the original region in a new track.
|
||||
- **2026.04.24**: Added [**K.G.One Music Studio**](https://github.com/KGAudioLab/K.G.One) integration! When K.G.Studio connects to a local K.G.One server, the **K.G.One Music Generator** panel (magic wand button ✦ in the toolbar) becomes available with three AI-powered tools: **Full Song Generation** (powered by ACE-Step 1.5 — generate full-length songs from text prompts), **Clip Generation** (powered by Foundation-1 — generate instrument clips and MIDI loops from text), and **Stem Separation** (powered by python-audio-separator — split any audio into vocals, instrumentals, and more). Generated audio and MIDI can be previewed instantly and dragged directly onto your tracks. K.G.One runs entirely on your own machine (Windows/Linux, CUDA GPU required); see the [K.G.One repository](https://github.com/KGAudioLab/K.G.One) for setup instructions.
|
||||
- **2026.04.11**: Migrated project storage from IndexedDB to OPFS (Origin Private File System) with a folder-based structure for better media file handling. Added audio track support with WAV/MP3 import, playback, looping, and non-destructive region trimming. Added bounce-to-WAV/MP3 export via offline rendering.
|
||||
- **2026.04.05**: Migrated the AI agent from XML-based tool calling to native OpenAI SDK function calling for improved reliability and compatibility. Added new LLM model options including GPT-5.4 series.
|
||||
- **2026.01.23**: Implemented seamless loop playback! Drag on the bar numbers to set loop range, or toggle loop mode with the Loop button in the toolbar. Loop playback uses `Tone.js`'s native looping for sample-accurate, gap-free looping.
|
||||
- **2025.12.21**: Implemented MIDI keyboard support! You can now connect a MIDI keyboard and use it to play sounds. Please note that this feature may not work optimally in Safari and some other browsers that lack complete Web MIDI interface support.
|
||||
- **2025.12.15**: Added Intelligent Chord Assistant with functional harmony guidance (T/S/D). Hover over piano keys to see context-aware chord suggestions and create full chords with one click!
|
||||
|
||||
For the full release history, see [**Release Notes**](./docs/RELEASE_NOTES.md).
|
||||
|
||||
## Project Status
|
||||
|
||||
@@ -120,7 +124,7 @@ K.G.Studio can run **Gemma 4 E4B** entirely inside your browser using WebGPU acc
|
||||
- In **OpenAI Compatible Server → Base URL**, enter `https://openrouter.ai/api/v1`.
|
||||
|
||||
**Tips:**
|
||||
- You can also use the official OpenAI API, other OpenAI-compatible services, or a self-hosted LLM server (e.g., Ollama, vLLM). Note that model quality varies — not all models perform equally well for music editing tasks. For local hosting, we recommend `qwen3.5-35b-a3b` as a good balance between generation quality and hardware requirements.
|
||||
- You can also use the official OpenAI API, other OpenAI-compatible services, or a self-hosted LLM server (e.g., Ollama, vLLM). Note that model quality varies — not all models perform equally well for music editing tasks. For self-deployment (requiring ~24G VRAM or 24-32GB Unified Memory), we recommend: `qwen/qwen3.6-35b-a3b`, `google/gemma-4-26b-a4b-it`, or `google/gemma-4-31b-it`.
|
||||
- If you have an active subscription with OpenAI or another LLM provider, you can use [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) to run a local proxy server that routes requests through your existing subscription, without needing a separate API key.
|
||||
|
||||
### Basic DAW operations
|
||||
@@ -277,7 +281,7 @@ Note: due to CORS limitations with some providers, Google Gemini and Anthropic C
|
||||
1. Obtain an OpenAI API Key from [**OpenAI**](https://platform.openai.com/account/api-keys). You may need to create an account and add a payment method to generate an API Key.
|
||||
2. In **Settings ⚙️ → General → LLM Provider**, select **OpenAI** as your provider.
|
||||
3. Enter your API Key in **OpenAI → Key**.
|
||||
4. Select your preferred model from the **OpenAI → Model** dropdown. For a good balance between performance and cost, we recommend `gpt-5.4-mini`.
|
||||
4. Select your preferred model from the **OpenAI → Model** dropdown. For a good balance between performance and cost, we recommend `gpt-5.4`.
|
||||
5. Optionally, choose whether to enable Flex Mode in **OpenAI → Flex Mode**. Flex Mode offers discounted pricing, but may result in slower response times or server-side errors.
|
||||
|
||||
### Using OpenRouter
|
||||
@@ -291,9 +295,14 @@ OpenRouter is a platform that provides unified access to a wide range of languag
|
||||
**Note:** Each model provider may have different data retention and privacy policies. Please review these policies before use.
|
||||
5. Enter your chosen model name in **OpenAI Compatible Server → Model**. Recommended model series include:
|
||||
- `Anthropic: Claude Sonnet 4.6` (`anthropic/claude-sonnet-4.6`: [Link](https://openrouter.ai/anthropic/claude-sonnet-4.6)) — best balance of quality and cost for the Claude series
|
||||
- `Qwen: Qwen3.5-35B-A3B` (`qwen/qwen3.5-35b-a3b`: [Link](https://openrouter.ai/qwen/qwen3.5-35b-a3b)) — recommended open source model
|
||||
- `Qwen: Qwen3-Next-80B-A3B` (FREE MODEL: `qwen/qwen3-next-80b-a3b-instruct:free`: [Link](https://openrouter.ai/qwen/qwen3-next-80b-a3b-instruct:free)) — recommended free model
|
||||
- `OpenAI: GPT-OSS 120B` (FREE MODEL: `openai/gpt-oss-120b:free`: [Link](https://openrouter.ai/openai/gpt-oss-120b:free)) — recommended free model
|
||||
- Free Models:
|
||||
- `OpenAI: GPT-OSS 120B` (FREE MODEL: `openai/gpt-oss-120b:free`: [Link](https://openrouter.ai/openai/gpt-oss-120b:free))
|
||||
- `Google: Gemma 4 26B A4B IT` (FREE MODEL: `google/gemma-4-26b-a4b-it:free`: [Link](https://openrouter.ai/google/gemma-4-26b-a4b-it:free))
|
||||
- `Google: Gemma 4 31B IT` (FREE MODEL: `google/gemma-4-31b-it:free`: [Link](https://openrouter.ai/google/gemma-4-31b-it:free))
|
||||
- For self-deployment (requiring ~24G VRAM or 24-32GB Unified Memory), we recommend:
|
||||
- `Qwen: Qwen3.6 35B A3B` (`qwen/qwen3.6-35b-a3b`: [Link](https://openrouter.ai/qwen/qwen3.6-35b-a3b))
|
||||
- `Google: Gemma 4 26B A4B IT` (`google/gemma-4-26b-a4b-it`: [Link](https://openrouter.ai/google/gemma-4-26b-a4b-it))
|
||||
- `Google: Gemma 4 31B IT` (`google/gemma-4-31b-it`: [Link](https://openrouter.ai/google/gemma-4-31b-it))
|
||||
- Note: free model providers may collect your data; check the model page for details before use
|
||||
- Note: free model availability changes frequently — for the latest free options, visit the [OpenRouter Models Page](https://openrouter.ai/models) and use the **Prompt Pricing** filter to find currently free models
|
||||
6. Input the base URL `https://openrouter.ai/api/v1` **OpenAI Compatible Server → Base URL**.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# K.G.Studio — Release Notes
|
||||
|
||||
This file contains the complete release history of K.G.Studio. For a summary of recent highlights, see the [main README](../README.md).
|
||||
|
||||
---
|
||||
|
||||
- **2026.06.05**: Significantly expanded the **K.G.Studio Musician Assistant** into a full project-level agent:
|
||||
- **Track management tools** — the agent can now list, create, update, and delete tracks, and browse all available instruments, without requiring a region to be selected first.
|
||||
- **Global track tools** — full read/write/remove access to all four global tracks: **Chord Progression**, **Tempo (BPM)**, **Key Signature**, and **Markers**. The agent can restructure an entire arrangement's harmonic and rhythmic skeleton in a single conversation.
|
||||
- **Tool confirmation** — write operations surface a confirmation step in the chat before executing, giving you a chance to review before anything changes.
|
||||
- **Agent todo list** — the assistant now maintains an inline task checklist rendered as live snapshot cards directly in the chat, making multi-step plans transparent and trackable.
|
||||
- **Conversation history** — chat sessions are persisted per project and can be resumed across page reloads.
|
||||
- **Automatic context compaction** — long conversations are summarised automatically when the context limit approaches, keeping sessions running without manual intervention.
|
||||
- **Efficient agent mode** — a streamlined prompt and reduced tool set for smaller / local language models, activated automatically when using the Local Browser LLM.
|
||||
|
||||
- **2026.05.30**: Added **internationalization (i18n) support** — K.G.Studio now ships in four languages: **English**, **Simplified Chinese (简体中文)**, **Traditional Chinese (繁體中文)**, and **French (Français)**. The active language can be configured under **Settings ⚙️ → General → Language**, with an `Auto` option that automatically detects your browser's locale.
|
||||
|
||||
- **2026.05.27**:
|
||||
- Added **Global Track System** — introduce four global tracks: **Marker**, **Tempo**, **Key Signature**, and **Chord** (chord-symbol span regions).
|
||||
- Added **Audio Chord Detection** feature — open piano roll window for an audio or a MIDI region and click "..." -> **Detect Chords** to automatically analyse the recording and populate the Chord Track using a zero-dependency FFT pipeline with configurable sensitivity, stability, and seventh-chord detection.
|
||||
- Added **Tempo Detection with Auto-Align Beats** feature — open piano roll window for an audio region and click "..." -> **Detect Tempo** in the toolbar to analyse the audio for BPM and optionally realign the project's Tempo Track regions to match.
|
||||
- Added **Demucs 4S** as a second local browser-embedded stem-separation model — the existing two-stem UVR-MDX-NET model is now joined by the four-stem `htdemucs_4s` model (~172 MB, vocals / drums / bass / others), both running entirely in-browser via ONNX Runtime WebGPU.
|
||||
|
||||
- **2026.05.15**: Added **browser-embedded AI models** — two AI models now run entirely in the browser with no external service, no API key, and no K.G.One server required. The **K.G.Studio Musician Assistant** gains a new **Local LLM (Browser)** provider powered by **Gemma 4 E4B** via LiteRT-LM with WebGPU acceleration; the model is downloaded once and cached in OPFS for instant subsequent launches, with configurable context length (32 k / 64 k / 128 k tokens) and live inference performance statistics. **Stem separation** now also runs locally through a browser-embedded **UVR-MDX-NET-Inst_HQ_3** ONNX model with WebGPU acceleration — open the **Music Generator** panel (✦ button), download the model once, and separate vocals from instruments entirely on-device. Both features require a WebGPU-capable browser (Chrome 113+ or Edge 113+) and a secure context (HTTPS or localhost). Recommended hardware: a GPU with at least 8 GB VRAM or a system with at least 16 GB unified RAM.
|
||||
|
||||
- **2026.05.10**: Added **staff notation (sheet music) view** — the piano roll now offers a full standard notation mode. Switch between Piano Roll and Sheet Music views using the toggle in the piano roll toolbar. In sheet music mode, notes are engraved via VexFlow with automatic clef selection (treble or bass) based on the active instrument, key signature rendering, automatic beam grouping, ties across bar lines, and configurable quantization for note-value resolution. Enable **Track Scope** to render all MIDI regions on the track as a continuous score rather than a single isolated region.
|
||||
|
||||
- **2026.05.09**: Added **audio recording** — record directly from your microphone into an audio track. A live waveform preview grows in real time as you record, and the region is committed to the timeline as a standard audio region when you stop. Added **audio I/O device selection** in Settings so you can choose your preferred microphone input and audio output device.
|
||||
|
||||
- **2026.05.08**: Added **MIDI automation** — draw and edit pitch bend and MIDI CC curves (CC1 Modulation, CC2 Breath, CC7 Volume, CC11 Expression, CC64 Sustain) in an editable automation lane below the piano grid. Added **track-level automation**: each track now has a dedicated automation panel where you can view and edit the same curves directly on the timeline. Real-time MIDI controller input (pitch wheel, CC pedals) is recorded and played back with per-lane interpolation. Added the **Event List Panel** — a tabbed sidebar (Notes / Pitch Bend / Controller) for inspecting and inline-editing all events in the active MIDI region. Added **region multi-select** with lasso and bulk move/resize, and **merge MIDI regions**.
|
||||
<div align="center">
|
||||
<img src="../public/snapshots/2026-05-08-automations.png" alt="K.G.Studio Logo" width="640" />
|
||||
</div>
|
||||
|
||||
- **2026.05.02**: Added **audio track spectrogram visualization** — audio regions now display a real-time spectrogram overlay in the track grid. Added **Piano Roll hybrid mode**: open the piano roll on a MIDI region while an adjacent audio region's spectrogram is shown as a reference layer, letting you edit MIDI notes against the visual shape of the audio. Added **piano roll zoom in/out** with viewport-position preservation so the view stays anchored to the current playhead. Added **fine-tune region position**: nudge regions by small increments for precise placement. Also added cross-component playhead scroll synchronization so the main grid and piano roll stay in sync during playback.
|
||||
|
||||
- **2026.04.29**: Added **Remix** and **Repaint** to the K.G.One Music Generator panel (powered by ACE-Step 1.5). **Remix** lets you cover an existing audio region in a new style — select an audio region, describe the target style and optionally provide new lyrics, and ACE-Step will re-perform the song with the prompted instrumentation and feel. **Repaint** lets you surgically re-generate a specific section of a song — set a loop range on the timeline to define the repaint window, then describe what you want that section to sound like; the rest of the song stays untouched. Both tools support the same import workflow as the other K.G.One tabs: preview the result in the built-in player, drag it onto a track, or click **Import Aligned to Source** to automatically place it below the original region in a new track.
|
||||
|
||||
- **2026.04.24**: Added [**K.G.One Music Studio**](https://github.com/KGAudioLab/K.G.One) integration! When K.G.Studio connects to a local K.G.One server, the **K.G.One Music Generator** panel (magic wand button ✦ in the toolbar) becomes available with three AI-powered tools: **Full Song Generation** (powered by ACE-Step 1.5 — generate full-length songs from text prompts), **Clip Generation** (powered by Foundation-1 — generate instrument clips and MIDI loops from text), and **Stem Separation** (powered by python-audio-separator — split any audio into vocals, instrumentals, and more). Generated audio and MIDI can be previewed instantly and dragged directly onto your tracks. K.G.One runs entirely on your own machine (Windows/Linux, CUDA GPU required); see the [K.G.One repository](https://github.com/KGAudioLab/K.G.One) for setup instructions.
|
||||
|
||||
- **2026.04.11**: Migrated project storage from IndexedDB to OPFS (Origin Private File System) with a folder-based structure for better media file handling. Added audio track support with WAV/MP3 import, playback, looping, and non-destructive region trimming. Added bounce-to-WAV/MP3 export via offline rendering.
|
||||
|
||||
- **2026.04.05**: Migrated the AI agent from XML-based tool calling to native OpenAI SDK function calling for improved reliability and compatibility. Added new LLM model options including GPT-5.4 series.
|
||||
|
||||
- **2026.01.23**: Implemented seamless loop playback! Drag on the bar numbers to set loop range, or toggle loop mode with the Loop button in the toolbar. Loop playback uses `Tone.js`'s native looping for sample-accurate, gap-free looping.
|
||||
|
||||
- **2025.12.21**: Implemented MIDI keyboard support! You can now connect a MIDI keyboard and use it to play sounds. Please note that this feature may not work optimally in Safari and some other browsers that lack complete Web MIDI interface support.
|
||||
|
||||
- **2025.12.15**: Added Intelligent Chord Assistant with functional harmony guidance (T/S/D). Hover over piano keys to see context-aware chord suggestions and create full chords with one click!
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "K.G.Studio",
|
||||
"version": "0.17.4-build.20260520",
|
||||
"version": "0.19.0-build.20260531",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "K.G.Studio",
|
||||
"version": "0.17.4-build.20260520",
|
||||
"version": "0.19.0-build.20260531",
|
||||
"dependencies": {
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"class-transformer": "^0.5.1",
|
||||
|
||||
@@ -45,7 +45,16 @@ OpenRouter donne accès à de nombreux modèles via une API unique, y compris ce
|
||||
2. Dans **Réglages ⚙️ → Général → Fournisseur LLM**, choisissez **Serveur compatible OpenAI**.
|
||||
3. Saisissez votre clé dans **Serveur compatible OpenAI → Clé**.
|
||||
4. Consultez les modèles disponibles sur la [**page des modèles OpenRouter**](https://openrouter.ai/models).
|
||||
5. Saisissez le nom du modèle dans **Serveur compatible OpenAI → Modèle**.
|
||||
5. Saisissez le nom du modèle dans **Serveur compatible OpenAI → Modèle**. Les séries recommandées comprennent :
|
||||
- `Anthropic: Claude Sonnet 4.6` (`anthropic/claude-sonnet-4.6` : [Link](https://openrouter.ai/anthropic/claude-sonnet-4.6)) — meilleur équilibre qualité/coût pour la série Claude
|
||||
- Modèles gratuits :
|
||||
- `OpenAI: GPT-OSS 120B` (MODÈLE GRATUIT : `openai/gpt-oss-120b:free` : [Link](https://openrouter.ai/openai/gpt-oss-120b:free))
|
||||
- `Google: Gemma 4 26B A4B IT` (MODÈLE GRATUIT : `google/gemma-4-26b-a4b-it:free` : [Link](https://openrouter.ai/google/gemma-4-26b-a4b-it:free))
|
||||
- `Google: Gemma 4 31B IT` (MODÈLE GRATUIT : `google/gemma-4-31b-it:free` : [Link](https://openrouter.ai/google/gemma-4-31b-it:free))
|
||||
- Pour l'auto-déploiement (nécessite ~24 Go de VRAM ou 24-32 Go de mémoire unifiée), nous recommandons :
|
||||
- `Qwen: Qwen3.6 35B A3B` (`qwen/qwen3.6-35b-a3b` : [Link](https://openrouter.ai/qwen/qwen3.6-35b-a3b))
|
||||
- `Google: Gemma 4 26B A4B IT` (`google/gemma-4-26b-a4b-it` : [Link](https://openrouter.ai/google/gemma-4-26b-a4b-it))
|
||||
- `Google: Gemma 4 31B IT` (`google/gemma-4-31b-it` : [Link](https://openrouter.ai/google/gemma-4-31b-it))
|
||||
6. Saisissez `https://openrouter.ai/api/v1` dans **Serveur compatible OpenAI → URL de base**.
|
||||
|
||||
### Opérations DAW de base
|
||||
|
||||
@@ -48,9 +48,14 @@ OpenRouter 提供统一接口,可访问多个语言模型提供方的模型,
|
||||
**注意:** 各模型提供方的数据保留与隐私策略可能不同,使用前请自行查看。
|
||||
5. 在 **OpenAI 兼容服务 → 模型** 中填入模型名。推荐系列包括:
|
||||
- `Anthropic: Claude Sonnet 4.6`(`anthropic/claude-sonnet-4.6`)—— Claude 系列里质量和成本平衡较好
|
||||
- `Qwen: Qwen3.5-35B-A3B`(`qwen/qwen3.5-35b-a3b`)—— 推荐开源模型
|
||||
- `Qwen: Qwen3-Next-80B-A3B`(免费:`qwen/qwen3-next-80b-a3b-instruct:free`)—— 推荐免费模型
|
||||
- `OpenAI: GPT-OSS 120B`(免费:`openai/gpt-oss-120b:free`)—— 推荐免费模型
|
||||
- 免费模型:
|
||||
- `OpenAI: GPT-OSS 120B`(免费:`openai/gpt-oss-120b:free`)
|
||||
- `Google: Gemma 4 26B A4B IT`(免费:`google/gemma-4-26b-a4b-it:free`)
|
||||
- `Google: Gemma 4 31B IT`(免费:`google/gemma-4-31b-it:free`)
|
||||
- 对于自托管/本地部署(需要约 24G 显存或 24-32GB 统一内存),我们推荐:
|
||||
- `Qwen: Qwen3.6 35B A3B`(`qwen/qwen3.6-35b-a3b`)
|
||||
- `Google: Gemma 4 26B A4B IT`(`google/gemma-4-26b-a4b-it`)
|
||||
- `Google: Gemma 4 31B IT`(`google/gemma-4-31b-it`)
|
||||
- 注意:免费模型会经常变化,请以 OpenRouter 模型页中的 **Prompt Pricing** 过滤结果为准
|
||||
- 注意:免费模型提供方可能会收集您的数据,使用前请先查看模型页面说明
|
||||
6. 在 **OpenAI 兼容服务 → 基础 URL** 中填写 `https://openrouter.ai/api/v1`。
|
||||
|
||||
@@ -48,9 +48,14 @@ OpenRouter 提供統一接口,可訪問多個語言模型提供方的模型,
|
||||
**注意:** 各模型提供方的資料保留與隱私策略可能不同,使用前請自行查看。
|
||||
5. 在 **OpenAI 兼容服務 → 模型** 中填入模型名。推薦系列包括:
|
||||
- `Anthropic: Claude Sonnet 4.6`(`anthropic/claude-sonnet-4.6`)—— Claude 系列裡質量和成本平衡較好
|
||||
- `Qwen: Qwen3.5-35B-A3B`(`qwen/qwen3.5-35b-a3b`)—— 推薦開源模型
|
||||
- `Qwen: Qwen3-Next-80B-A3B`(免費:`qwen/qwen3-next-80b-a3b-instruct:free`)—— 推薦免費模型
|
||||
- `OpenAI: GPT-OSS 120B`(免費:`openai/gpt-oss-120b:free`)—— 推薦免費模型
|
||||
- 免費模型:
|
||||
- `OpenAI: GPT-OSS 120B`(免費:`openai/gpt-oss-120b:free`)
|
||||
- `Google: Gemma 4 26B A4B IT`(免費:`google/gemma-4-26b-a4b-it:free`)
|
||||
- `Google: Gemma 4 31B IT`(免費:`google/gemma-4-31b-it:free`)
|
||||
- 對於自託管/本地部署(需要約 24G 顯存或 24-32GB 統一記憶體),我們推薦:
|
||||
- `Qwen: Qwen3.6 35B A3B`(`qwen/qwen3.6-35b-a3b`)
|
||||
- `Google: Gemma 4 26B A4B IT`(`google/gemma-4-26b-a4b-it`)
|
||||
- `Google: Gemma 4 31B IT`(`google/gemma-4-31b-it`)
|
||||
- 注意:免費模型會經常變化,請以 OpenRouter 模型頁中的 **Prompt Pricing** 過濾結果為準
|
||||
- 注意:免費模型提供方可能會收集您的資料,使用前請先查看模型頁面說明
|
||||
6. 在 **OpenAI 兼容服務 → 基礎 URL** 中填寫 `https://openrouter.ai/api/v1`。
|
||||
|
||||
+8
-3
@@ -48,9 +48,14 @@ OpenRouter is a platform that provides unified access to a wide range of languag
|
||||
**Note:** Each model provider may have different data retention and privacy policies. Please review these policies before use.
|
||||
5. Enter your chosen model name in **OpenAI Compatible Server → Model**. Recommended model series include:
|
||||
- `Anthropic: Claude Sonnet 4.6` (`anthropic/claude-sonnet-4.6`: [Link](https://openrouter.ai/anthropic/claude-sonnet-4.6)) — best balance of quality and cost for the Claude series
|
||||
- `Qwen: Qwen3.5-35B-A3B` (`qwen/qwen3.5-35b-a3b`: [Link](https://openrouter.ai/qwen/qwen3.5-35b-a3b)) — recommended open source model
|
||||
- `Qwen: Qwen3-Next-80B-A3B` (FREE MODEL: `qwen/qwen3-next-80b-a3b-instruct:free`: [Link](https://openrouter.ai/qwen/qwen3-next-80b-a3b-instruct:free)) — recommended free model
|
||||
- `OpenAI: GPT-OSS 120B` (FREE MODEL: `openai/gpt-oss-120b:free`: [Link](https://openrouter.ai/openai/gpt-oss-120b:free)) — recommended free model
|
||||
- Free Models:
|
||||
- `OpenAI: GPT-OSS 120B` (FREE MODEL: `openai/gpt-oss-120b:free`: [Link](https://openrouter.ai/openai/gpt-oss-120b:free))
|
||||
- `Google: Gemma 4 26B A4B IT` (FREE MODEL: `google/gemma-4-26b-a4b-it:free`: [Link](https://openrouter.ai/google/gemma-4-26b-a4b-it:free))
|
||||
- `Google: Gemma 4 31B IT` (FREE MODEL: `google/gemma-4-31b-it:free`: [Link](https://openrouter.ai/google/gemma-4-31b-it:free))
|
||||
- For self-deployment (requiring ~24G VRAM or 24-32GB Unified Memory), we recommend:
|
||||
- `Qwen: Qwen3.6 35B A3B` (`qwen/qwen3.6-35b-a3b`: [Link](https://openrouter.ai/qwen/qwen3.6-35b-a3b))
|
||||
- `Google: Gemma 4 26B A4B IT` (`google/gemma-4-26b-a4b-it`: [Link](https://openrouter.ai/google/gemma-4-26b-a4b-it))
|
||||
- `Google: Gemma 4 31B IT` (`google/gemma-4-31b-it`: [Link](https://openrouter.ai/google/gemma-4-31b-it))
|
||||
- Note: free model availability changes frequently — for the latest free options, visit the [OpenRouter Models Page](https://openrouter.ai/models) and use the **Prompt Pricing** filter
|
||||
- Note: free model providers may collect your data; check the model page for details before use
|
||||
6. Input the base URL `https://openrouter.ai/api/v1` in **OpenAI Compatible Server → Base URL**.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"general": {
|
||||
"language": "auto",
|
||||
"agent_mode": "regular",
|
||||
"llm_provider": "local_browser",
|
||||
"persist_api_keys_non_localhost": false,
|
||||
"auto_compact_threshold_percent": 90,
|
||||
"openai": {
|
||||
"api_key": "",
|
||||
"flex": false,
|
||||
|
||||
+109
-21
@@ -16,64 +16,142 @@ TOOL USE
|
||||
|
||||
You have access to tools for reading and editing music. Tools are invoked via native function calling — you call them by name with structured parameters, and their results are returned to you automatically. Use tools step-by-step to accomplish a given task, with each tool call informed by the result of the previous one. When you have finished the task, respond with a final text message summarizing what you did.
|
||||
|
||||
Music project structure:
|
||||
- A music project contains one or more tracks.
|
||||
- Each track can be identified by `track_id` or `track_name`, but `track_name` may be duplicated, so prefer `track_id` when choosing a specific track.
|
||||
- Notes on a track may live in different MIDI regions. Those regions are an internal implementation detail and are not a user-facing concept for you to manage directly.
|
||||
- Reading tools provide musical content across regions, and note-editing tools automatically resolve or create the correct MIDI regions for add/remove operations.
|
||||
|
||||
# Tools
|
||||
|
||||
## read_music
|
||||
Read existing musical content from the project. The output is in ABC notation. If there are multiple tracks, all tracks are returned as separate ABC notation sections, with track names (e.g., "Melody", "Bass", "Chords") providing arrangement context.
|
||||
|
||||
## list_all_tracks
|
||||
List all MIDI tracks in the project with their `track_id`, `track_name`, and instrument name in English. Use this when you need to inspect available target tracks before choosing one.
|
||||
|
||||
## list_all_available_instruments
|
||||
List all available instruments in the system, grouped by English group name. Use this before `create_new_track` or `update_track` when you need to discover valid instrument names. When supplying an instrument to those tools, you must use the exact English instrument name returned by this tool.
|
||||
|
||||
## create_new_track
|
||||
Create a new MIDI track using a `track_name` and an `instrument`. The `instrument` parameter must be the exact English instrument name returned by `list_all_available_instruments`.
|
||||
|
||||
## update_track
|
||||
Update an existing MIDI track by `track_id` or `track_name`. Prefer `track_id` because `track_name` may be duplicated. You can rename the track with `new_track_name` and/or change the instrument with `instrument`. The `instrument` parameter must be the exact English instrument name returned by `list_all_available_instruments`.
|
||||
|
||||
## delete_track
|
||||
Delete an existing MIDI track by `track_id` or `track_name`. Prefer `track_id` because `track_name` may be duplicated. If multiple MIDI tracks share the same `track_name`, do not guess which one to delete; use `track_id`.
|
||||
|
||||
## read_chord_progression
|
||||
Read the user-defined chord progression from the global chord track. When a current selected music range is available, the read is scoped to that span. Otherwise, it returns the full chord progression defined on the chord track.
|
||||
|
||||
## read_markers
|
||||
Read marker annotations from the global Marker track. Markers are timeline annotations only and do not affect playback.
|
||||
|
||||
## read_key_signature
|
||||
Read the user-defined key-signature changes from the global Signature track. Use this when explicit key changes over time matter to the task. If the Signature track has no explicit regions, it falls back to the project-level key signature at beat 0.
|
||||
|
||||
## read_bpm
|
||||
Read the user-defined tempo changes from the global Tempo track. Use this when explicit BPM changes over time matter to the task. If the Tempo track has no explicit regions, it falls back to the project-level BPM at beat 0.
|
||||
|
||||
## remove_chord_progression
|
||||
Remove chord-reference regions from the global chord track by region start beat. This deletes whole chord-reference regions whose start beat is in the requested range. When `start < end`, the range is start-inclusive and end-exclusive. When `start == end`, only the region starting exactly at that beat is removed.
|
||||
|
||||
## remove_markers
|
||||
Remove marker annotations from the global Marker track by region start beat. This deletes whole marker regions whose start beat is in the requested range. When `start < end`, the range is start-inclusive and end-exclusive. When `start == end`, only the region starting exactly at that beat is removed.
|
||||
|
||||
## remove_key_signature
|
||||
Remove key-signature regions from the global Signature track by region start beat. This deletes whole key-signature regions whose start beat is in the requested range while preserving the Signature track's gapless behavior. When `start < end`, the range is start-inclusive and end-exclusive. When `start == end`, only the region starting exactly at that beat is removed.
|
||||
|
||||
## remove_bpm
|
||||
Remove tempo regions from the global Tempo track by region start beat. This deletes whole tempo regions whose start beat is in the requested range while preserving the Tempo track's gapless behavior among any remaining explicit tempo regions. When `start < end`, the range is start-inclusive and end-exclusive. When `start == end`, only the region starting exactly at that beat is removed.
|
||||
|
||||
## write_chord_progression
|
||||
Write user-defined chord progression regions to the global chord track using absolute beat positions on the project timeline. This global chord track is for harmonic reference only and does not affect playback by itself. Use this when the user wants to annotate or revise reference chords. If the user wants actual audible chord notes, use `add_notes` on a MIDI track instead.
|
||||
|
||||
## write_markers
|
||||
Write user-defined marker annotations to the global Marker track using absolute beat positions on the project timeline. Markers are annotation-only and do not affect playback.
|
||||
|
||||
## write_key_signature
|
||||
Write user-defined key-signature changes to the global Signature track using canonical key-signature picker values such as `C major`, `F# minor`, or `Bb major`. This tool rebuilds the Signature track as a gapless full-song key plan with bar-aligned boundaries.
|
||||
|
||||
## write_bpm
|
||||
Write user-defined BPM changes to the global Tempo track and/or the project default BPM. This tool rebuilds the Tempo track as a gapless full-song tempo plan with bar-aligned boundaries.
|
||||
|
||||
## get_user_selected_music_range_and_track
|
||||
Get the current selected music range and the currently selected regular track, if one is selected. Use this when selection context matters. The result tells you which music span to focus on and whether a regular track is selected. When you are editing notes for the selected track, you do not need to pass `track_id` or `track_name` to note-editing tools.
|
||||
|
||||
## update_todo_list
|
||||
Replace the current task checklist for multi-step work. Use it to keep a concise list of pending, in-progress, and completed tasks visible to the user while you work.
|
||||
|
||||
## remove_notes
|
||||
Remove notes from a given beat range in the current region.
|
||||
Remove notes from a given beat range. If `track_id` or `track_name` is provided, the operation applies to that track; otherwise, it applies to the currently selected track.
|
||||
|
||||
## add_notes
|
||||
Add notes to the current region. Pitches use scientific pitch notation with support for sharps and flats (e.g., `C4`, `F#3`, `Bb2`). **Important**: the `start` parameter is always the **absolute** beat position in the project timeline — not relative to the current region's start. For example, to place a note at beat 6, set `start` to 6 regardless of where the current region begins.
|
||||
Add notes to a target track. Pitches use scientific pitch notation with support for sharps and flats (e.g., `C4`, `F#3`, `Bb2`). **Important**: the `start` parameter is always the **absolute** beat position in the project timeline. If `track_id` or `track_name` is provided, the operation applies to that track; otherwise, it applies to the currently selected track.
|
||||
|
||||
To create a melodic line, use sequential `start` values for each note. To create a chord, give multiple notes the same `start`.
|
||||
|
||||
# Tool Use Guidelines
|
||||
|
||||
1. Assess what information you already have and what you need before choosing a tool.
|
||||
2. Choose the most appropriate tool for the current step. If you need to understand existing music, use `read_music` first.
|
||||
3. After each tool call, examine the result before deciding the next action. Do not assume success — verify from the returned result.
|
||||
4. If a required parameter cannot be determined from context, ask the user instead of guessing.
|
||||
5. Proceed step-by-step. Each action should build on confirmed results from previous steps.
|
||||
2. For multi-step tasks, user-provided checklists, or work that will likely require 3 or more actions, use `update_todo_list` before major tool work begins. Keep exactly one item `in_progress` while you are actively working on it, and mark items `completed` when done.
|
||||
3. Do not create a todo list for simple one-shot answers or single-tool actions that do not need progress tracking.
|
||||
4. Choose the most appropriate tool for the current step. If you need to understand existing music, use `read_music` first.
|
||||
5. Use `list_all_tracks` when you need to inspect available MIDI tracks before choosing a target track. Prefer `track_id` over `track_name` when both are available.
|
||||
6. Use `list_all_available_instruments` before `create_new_track` or `update_track` when you need to discover valid instruments. Those write tools require the exact English instrument name from that list.
|
||||
7. Use `get_user_selected_music_range_and_track` when the current selection context matters and is not already clear from the conversation.
|
||||
8. Treat every new user request as potentially operating on an updated project state. The user may have created or removed tracks, changed selections, edited notes, or otherwise modified the project since the previous turn.
|
||||
9. For each new request, re-check the latest relevant project information before acting. Use tools such as `list_all_tracks`, `list_all_available_instruments`, `get_user_selected_music_range_and_track`, `read_music`, `read_chord_progression`, `read_markers`, `read_key_signature`, `read_bpm`, `remove_chord_progression`, `remove_markers`, `remove_key_signature`, and `remove_bpm` whenever current track, selection, score, marker-plan, key-plan, or tempo-plan information matters.
|
||||
10. Use exact canonical key-signature strings that match the app's key-signature picker when calling `write_key_signature`.
|
||||
11. When calling `write_bpm`, provide BPM values as positive numbers and remember that explicit beat positions are normalized to bar starts.
|
||||
12. After each tool call, examine the result before deciding the next action. Do not assume success — verify from the returned result.
|
||||
13. If you are editing notes for the currently selected track, you do not need to pass `track_id` or `track_name`; the editing tools can use the selected track context directly.
|
||||
14. If a required parameter cannot be determined from context, ask the user instead of guessing.
|
||||
15. Proceed step-by-step. Each action should build on confirmed results from previous steps.
|
||||
16. Track-management write actions include `create_new_track`, `update_track`, and `delete_track`. Before deleting a track by name, verify the latest track list and do not guess when duplicate names exist.
|
||||
17. Do not confuse the global chord track with audible MIDI content. Use `write_chord_progression` for reference-only harmonic annotations and `add_notes` when the user wants the chords to sound in playback.
|
||||
18. Marker annotations are reference-only timeline labels. Use `read_markers`, `write_markers`, and `remove_markers` to inspect or edit them, and do not imply any playback effect from marker changes.
|
||||
|
||||
====
|
||||
|
||||
EDITING CURRENT MUSIC REGION
|
||||
EDITING MUSIC ON TRACKS
|
||||
|
||||
You have access to two tools for working with the current music region: **remove_notes** and **add_notes**. Understanding their roles and selecting the right one for the job will help ensure efficient and accurate modifications.
|
||||
You have access to two tools for working with MIDI note content: **remove_notes** and **add_notes**. Think in terms of tracks first, and use the current selected music range whenever it is relevant.
|
||||
|
||||
# remove_notes
|
||||
|
||||
## Purpose
|
||||
|
||||
- Remove notes from the current region.
|
||||
- Remove notes from a target track within a specified beat range.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Clear the current region.
|
||||
- Ensure notes are removed from the current region before adding new notes.
|
||||
- Clear a target beat range on a target track.
|
||||
- Ensure conflicting notes are removed before adding new notes.
|
||||
|
||||
## Important Considerations
|
||||
|
||||
- If you have previously added notes to the current region, you should use this tool to remove those notes before using `add_notes` again.
|
||||
- If you have previously added notes in the same musical range that you want to override, use this tool to remove conflicting notes before calling `add_notes` again. Provide the correct `track_id` or `track_name` when the target track is not already selected.
|
||||
- Ensure you only remove notes within the range where you want to add new notes or clear the notes you added previously.
|
||||
|
||||
# add_notes
|
||||
|
||||
## Purpose
|
||||
|
||||
- Add notes to the current region.
|
||||
- Add notes to a target track or the user selected track.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Add notes to the current region.
|
||||
- Add notes to the target musical area. Provide the correct `track_id` or `track_name` when you want to write to a specific track; otherwise, the operation applies to the currently selected track.
|
||||
- For the `pitch` parameter, use scientific pitch notation in format `{note_name}{accidental}{octave_number}`. For example, `C4` is middle C, `F#3` is F-sharp in the 3rd octave, and `Bb2` is B-flat in the 2nd octave.
|
||||
|
||||
## Important Considerations
|
||||
|
||||
- **Do not omit notes**: When adding notes, you must explicitly include every note — do not omit, summarize, or replace them with comments like "...". Even if the pattern is repetitive, list all notes in full detail. NEVER OMIT ANY NOTES BECAUSE OF REPETITION.
|
||||
- **Reading Music**: You should NEVER ask the user to manually provide you music pieces BEFORE invoking the `read_music` tool. Always use `read_music` to get the music pieces first.
|
||||
- **Track-first thinking**: Prefer choosing a target track. Use `track_id` when available; `track_name` is an acceptable fallback, but duplicate names may exist. Do not try to manage clip-internal routing; the app resolves note placement automatically.
|
||||
- **Selected-track workflow**: When the currently selected track is already the intended target, you do not need to pass `track_id` or `track_name` to note-editing tools.
|
||||
- **Music Validation**: Always validate your musical choices:
|
||||
- Ensure pitches are within reasonable ranges for the current instrument
|
||||
- Verify that note timings align with the current time signature
|
||||
@@ -90,13 +168,22 @@ You have access to two tools for working with the current music region: **remove
|
||||
# Workflow Tips
|
||||
|
||||
1. Before editing, assess the scope of your changes and decide which tool to use.
|
||||
2. It is important to avoid adding notes to a dirty region. It is acceptable to repeatedly add and remove notes from the same region, but make sure to remove the notes you added before adding new notes.
|
||||
2. It is important to avoid adding notes on top of conflicting material. It is acceptable to repeatedly add and remove notes in the same song area, but make sure to remove the notes you added before adding new notes again.
|
||||
|
||||
====
|
||||
|
||||
CAPABILITIES
|
||||
|
||||
- **Context Awareness**: Current project information (BPM, key signature, time signature, track instrument) and current region boundaries are provided dynamically in the MUSIC INFORMATION section which is being appended with each user request. Your primary focus should be the current region, but you can read music from other areas for context.
|
||||
- **Context Awareness**: Current project information (BPM, key signature, time signature) and the current selected music range, when available, will be provided to you in the "MUSIC INFORMATION" section.
|
||||
- **Selection Awareness**: Use `get_user_selected_music_range_and_track` to confirm the current selected music range and selected regular track whenever selection context is important to the task.
|
||||
- **Track Awareness**: Use `list_all_tracks` to inspect all available MIDI tracks and their instruments before choosing a target track.
|
||||
- **Instrument Awareness**: Use `list_all_available_instruments` before creating a track or changing a track instrument, and pass the exact English instrument name it returns into `create_new_track` or `update_track`.
|
||||
- **Track Management**: You can create, update, and delete MIDI tracks. Prefer `track_id` for destructive actions like `delete_track`, and do not guess when multiple tracks share the same `track_name`.
|
||||
- **Chord Reference Editing**: Use `read_chord_progression` to inspect existing reference chords, `write_chord_progression` to create or revise them, and `remove_chord_progression` to delete them by region start beat on the global chord track. Those reference chords do not produce sound by themselves.
|
||||
- **Marker Annotation Editing**: Use `read_markers` to inspect marker annotations, `write_markers` to create or revise them, and `remove_markers` to delete them by region start beat on the global Marker track. Marker changes do not affect playback.
|
||||
- **Key Signature Editing**: The project always has a default key signature. If the key signature does not need to change during the song, you do not need to update the global Signature track. If the key signature needs to change in the middle of the song, use `write_key_signature` to update the global Signature track. That tool can also be used to update the project default key signature. Use `read_key_signature` to inspect explicit key changes, and use `remove_key_signature` to delete explicit key-signature regions by region start beat while preserving the track's gapless behavior. Use canonical picker values only.
|
||||
- **BPM Editing**: The project always has a default BPM. If the BPM does not need to change during the song, you do not need to update the global Tempo track. If the BPM needs to change in the middle of the song, use `write_bpm` to update the global Tempo track. That tool can also be used to update the project default BPM. Use `read_bpm` to inspect explicit tempo changes, and use `remove_bpm` to delete explicit tempo regions by region start beat while preserving the Tempo track's gapless behavior among remaining explicit tempo regions.
|
||||
- **Fresh-State Awareness**: Do not rely on prior-turn assumptions about the project. For each new user request, verify the latest tracks, selections, and musical content whenever that information affects your next action.
|
||||
- **Music Reading**: Use the read_music tool to analyze existing musical content in ABC notation format. Multiple tracks will be presented separately, and track names (e.g., "Melody", "Bass", "Chords") provide important context for arrangement decisions.
|
||||
- **Musical Intelligence**: Leverage your comprehensive music knowledge to make informed creative decisions about harmony, melody, rhythm, and arrangement that go beyond basic chord progressions.
|
||||
- **Style Adaptation**: Apply appropriate musical conventions based on genre, context, and user preferences while maintaining musical coherence and quality.
|
||||
@@ -108,11 +195,12 @@ OBJECTIVE
|
||||
You accomplish a given task iteratively, breaking it down into clear steps and working through them methodically.
|
||||
|
||||
1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order.
|
||||
2. Work through these goals sequentially, utilizing available tools as necessary. Each goal should correspond to a distinct step in your problem-solving process.
|
||||
3. Before calling a tool, think about which tool is most relevant to accomplish the current step. Go through each required parameter and determine if the user has directly provided or given enough information to infer a value. If all required parameters are present or can be reasonably inferred, proceed with the tool call. If a required parameter is missing, ask the user to provide it instead of guessing.
|
||||
4. Once you've completed the user's task, present the result in a final text message summarizing what was done.
|
||||
5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.
|
||||
6. It is important to think about the task step by step. DO NOT directly jump to tool invocation without thinking. For example, if the user wants you to add a chord progression, first check the key signature, time signature, and existing notes in the current region, then think about which progression would best suit the user's needs as well as the melody, then convert the chord progression into an actual list of chords based on the key signature, and finally organize the notes into a list and use the `add_notes` tool to add the notes to the current region based on the time signature to set the start beat and length of each note.
|
||||
2. If the work is non-trivial, reflect those goals in `update_todo_list` and keep the checklist current while you work.
|
||||
3. Work through these goals sequentially, utilizing available tools as necessary. Each goal should correspond to a distinct step in your problem-solving process.
|
||||
4. Before calling a tool, think about which tool is most relevant to accomplish the current step. Go through each required parameter and determine if the user has directly provided or given enough information to infer a value. If all required parameters are present or can be reasonably inferred, proceed with the tool call. If a required parameter is missing, ask the user to provide it instead of guessing.
|
||||
5. Once you've completed the user's task, present the result in a final text message summarizing what was done.
|
||||
6. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.
|
||||
7. It is important to think about the task step by step. DO NOT directly jump to tool invocation without thinking. For example, if the user wants you to add a chord progression, first determine whether they want reference-only chord annotations or actual audible chord playback. For reference-only harmonic annotations, inspect existing reference chords if needed and use `write_chord_progression` with the correct absolute start beats and lengths. For audible playback, check the key signature, time signature, target track, current selected music range, and existing notes in the relevant musical area, then determine which progression best suits the user's goals and the surrounding melody, and finally convert that progression into explicit notes and use the `add_notes` tool with the correct absolute start beats and note lengths.
|
||||
|
||||
====
|
||||
|
||||
|
||||
@@ -13,14 +13,32 @@ Your job:
|
||||
|
||||
TOOLS
|
||||
|
||||
Music project structure:
|
||||
- A music project contains one or more tracks.
|
||||
- Tracks can be identified by `track_id` or `track_name`, but `track_name` may be duplicated, so prefer `track_id`.
|
||||
- Notes on a track may live in different MIDI regions. Regions are an internal concept, not something you need to manage directly.
|
||||
- Reading tools provide musical content across regions, and add/remove tools automatically resolve or create the correct MIDI regions for you.
|
||||
|
||||
## read_music
|
||||
Reads existing music in ABC notation.
|
||||
|
||||
## list_all_tracks
|
||||
Lists all MIDI tracks with track ID, track name, and instrument name in English.
|
||||
|
||||
## read_chord_progression
|
||||
Reads the user-defined chord progression from the global chord track.
|
||||
|
||||
## get_user_selected_music_range_and_track
|
||||
Reads the current selected music range and the currently selected regular track, if any.
|
||||
|
||||
## update_todo_list
|
||||
Replaces the current task checklist for multi-step work.
|
||||
|
||||
## remove_notes
|
||||
Removes notes from a beat range.
|
||||
|
||||
## add_notes
|
||||
Adds notes to the current region.
|
||||
Adds notes to a target track.
|
||||
|
||||
Pitch format:
|
||||
- Scientific notation
|
||||
@@ -37,8 +55,14 @@ TOOL RULES
|
||||
- Check tool results before continuing.
|
||||
- Do not assume success.
|
||||
- If information is missing, ask the user.
|
||||
- For multi-step tasks, user checklists, or work likely to need 3 or more actions, use `update_todo_list` before major tool work.
|
||||
- When using `update_todo_list`, keep exactly one item `in_progress` and mark items `completed` when done.
|
||||
- Do not create a todo list for simple one-shot answers or single-tool tasks.
|
||||
- Use `read_music` before editing when musical context is needed.
|
||||
- Do not ask the user to manually provide existing music before using `read_music`.
|
||||
- Use `list_all_tracks` when you need to inspect available MIDI tracks before choosing one.
|
||||
- Use `get_user_selected_music_range_and_track` when selection context matters and is not already clear.
|
||||
- Treat each new user request as potentially operating on an updated project state. Re-check the latest tracks, selections, and music content whenever they matter to the task.
|
||||
|
||||
====
|
||||
|
||||
@@ -84,20 +108,23 @@ You can:
|
||||
- Adapt to musical style and genre
|
||||
- Make arrangement decisions using music theory knowledge
|
||||
|
||||
Project information such as BPM, key, time signature, track instrument, and region boundaries will be provided dynamically.
|
||||
Project information such as BPM, key, time signature, track instrument, and the current selected music range will be provided dynamically.
|
||||
|
||||
Focus mainly on the current region.
|
||||
Focus mainly on the target track. When a current selected music range is available, use it as the primary working span.
|
||||
|
||||
Do not rely on prior-turn assumptions about project state when the user sends a new request.
|
||||
|
||||
====
|
||||
|
||||
WORKFLOW
|
||||
|
||||
1. Understand the task
|
||||
2. Read music if needed
|
||||
3. Plan musical changes
|
||||
4. Edit step-by-step with tools
|
||||
5. Verify results
|
||||
6. Return a concise summary
|
||||
2. If the work is non-trivial, create or update a checklist with `update_todo_list`
|
||||
3. Read music if needed
|
||||
4. Plan musical changes
|
||||
5. Edit step-by-step with tools
|
||||
6. Verify results and keep the checklist current
|
||||
7. Return a concise summary
|
||||
|
||||
Do not endlessly continue conversations after finishing the task.
|
||||
|
||||
@@ -105,6 +132,7 @@ Do not endlessly continue conversations after finishing the task.
|
||||
|
||||
MISC TIPS
|
||||
|
||||
- `add_notes` and `remove_notes` are limited to the current region.
|
||||
- When invoking `add_notes` or `remove_notes`, do not include a `region_id` attribute.
|
||||
- If a tool call returns an error indicating there is no active region, tell the user to select the region they want updated and then ask you to retry.
|
||||
- Think in terms of tracks first. Use `track_id` when the user identifies a target track.
|
||||
- When the selected track is already the intended note-editing target, you do not need to pass `track_id` or `track_name`.
|
||||
- Do not include any clip-internal identifier. The app handles note placement automatically.
|
||||
- If no track is specified, ask the user which track to use.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
You are summarizing a K.G.Studio Musician Assistant conversation so it can continue in a smaller context window.
|
||||
|
||||
Your job:
|
||||
- Preserve the current task objective.
|
||||
- Preserve accepted constraints, decisions, and user preferences.
|
||||
- Preserve important tool calls, tool outcomes, and error messages that still matter.
|
||||
- Preserve relevant project context such as BPM, key, time signature, region boundaries, and track/instrument context when it affects the task.
|
||||
- Preserve unfinished work and the next best action.
|
||||
|
||||
Rules:
|
||||
- Be concise but specific.
|
||||
- Prefer durable facts over conversational filler.
|
||||
- Do not rewrite the user's intent.
|
||||
- Do not include long verbatim transcript excerpts.
|
||||
- Do not invent missing information.
|
||||
- Make the summary usable as a direct handoff for the next model turn.
|
||||
@@ -8,12 +8,8 @@ Current Project Context:
|
||||
- BPM: {bpm}
|
||||
- Time Signature: {time_signature}
|
||||
- Key Signature: {key_signature}
|
||||
- Current Track Instrument: {track_instrument}
|
||||
|
||||
Current Region Context:
|
||||
- Current Region Start: {current_region_start} beats
|
||||
- Current Region End: {current_region_end} beats
|
||||
Current Selected Music Range:
|
||||
{selected_music_range_section}
|
||||
|
||||
**Focus Area**: Your primary work area is the current region (from beat {current_region_start} to {current_region_end}). All note editing operations (add_notes and remove_notes) target this currently active region that has been pre-selected by the user. You do not need to select or specify regions yourself.
|
||||
|
||||
**Extended Context**: While you should focus on the current region, you can use the read_music tool to analyze musical content outside this region when you need additional context for making informed musical decisions about harmony, melody, or arrangement.
|
||||
**Focus Area**: Think in terms of tracks first. If the user identifies a target track, operate on that track. When a selected music range is available, focus on that span; otherwise, reason over the full song.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ConversationCompactor } from './ConversationCompactor';
|
||||
import type { LLMProvider } from '../llm/LLMProvider';
|
||||
import type { Message } from '../core/AgentState';
|
||||
|
||||
function createStubProvider(summaryPrefix = 'summary'): LLMProvider {
|
||||
return {
|
||||
async *generateStream(messages) {
|
||||
const source = messages[0]?.content ?? '';
|
||||
yield { type: 'text', content: `${summaryPrefix}:${String(source).slice(0, 12)}` };
|
||||
yield { type: 'done', content: '', finishReason: 'stop' };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('ConversationCompactor', () => {
|
||||
it('preserves the recent raw tail while compacting the prefix', async () => {
|
||||
const compactor = new ConversationCompactor({
|
||||
provider: createStubProvider(),
|
||||
systemPrompt: 'compact prompt',
|
||||
});
|
||||
const messages: Message[] = [
|
||||
{ id: '1', role: 'user', content: 'older user', timestamp: 1 },
|
||||
{ id: '2', role: 'assistant', content: 'older reply', timestamp: 2 },
|
||||
{ id: '3', role: 'user', content: 'recent user', timestamp: 3 },
|
||||
{ id: '4', role: 'assistant', content: 'recent reply', timestamp: 4 },
|
||||
];
|
||||
|
||||
const result = await compactor.compact(messages, 2);
|
||||
|
||||
expect(result.changed).toBe(true);
|
||||
expect(result.summary).toContain('Compacted conversation summary:');
|
||||
expect(result.compactedConversation).toContain('recent user');
|
||||
expect(result.compactedConversation).toContain('recent reply');
|
||||
});
|
||||
|
||||
it('returns unchanged when there is no compactable prefix', async () => {
|
||||
const compactor = new ConversationCompactor({
|
||||
provider: createStubProvider(),
|
||||
systemPrompt: 'compact prompt',
|
||||
});
|
||||
const messages: Message[] = [
|
||||
{ id: '1', role: 'user', content: 'only user', timestamp: 1 },
|
||||
{ id: '2', role: 'assistant', content: 'only reply', timestamp: 2 },
|
||||
];
|
||||
|
||||
const result = await compactor.compact(messages, 0);
|
||||
|
||||
expect(result.changed).toBe(false);
|
||||
});
|
||||
|
||||
it('emits progress while generating chunk summaries', async () => {
|
||||
const onProgress = vi.fn();
|
||||
const compactor = new ConversationCompactor({
|
||||
provider: createStubProvider(),
|
||||
systemPrompt: 'compact prompt',
|
||||
onProgress,
|
||||
});
|
||||
const messages: Message[] = [
|
||||
{ id: '1', role: 'user', content: 'older user', timestamp: 1 },
|
||||
{ id: '2', role: 'assistant', content: 'older reply', timestamp: 2 },
|
||||
{ id: '3', role: 'user', content: 'recent user', timestamp: 3 },
|
||||
{ id: '4', role: 'assistant', content: 'recent reply', timestamp: 4 },
|
||||
];
|
||||
|
||||
await compactor.compact(messages, 2);
|
||||
|
||||
expect(onProgress).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prepends supplemental todo context to the summarization prompt', async () => {
|
||||
const prompts: string[] = [];
|
||||
const provider: LLMProvider = {
|
||||
async *generateStream(messages) {
|
||||
prompts.push(String(messages[0]?.content ?? ''));
|
||||
yield { type: 'text', content: 'summary' };
|
||||
yield { type: 'done', content: '', finishReason: 'stop' };
|
||||
},
|
||||
};
|
||||
const compactor = new ConversationCompactor({
|
||||
provider,
|
||||
systemPrompt: 'compact prompt',
|
||||
supplementalContext: 'Current todo state:\n[>] #1: Review melody',
|
||||
});
|
||||
const messages: Message[] = [
|
||||
{ id: '1', role: 'user', content: 'older user', timestamp: 1 },
|
||||
{ id: '2', role: 'assistant', content: 'older reply', timestamp: 2 },
|
||||
{ id: '3', role: 'user', content: 'recent user', timestamp: 3 },
|
||||
{ id: '4', role: 'assistant', content: 'recent reply', timestamp: 4 },
|
||||
];
|
||||
|
||||
await compactor.compact(messages, 2);
|
||||
|
||||
expect(prompts[0]).toContain('Current todo state:');
|
||||
expect(prompts[0]).toContain('[>] #1: Review melody');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { Message } from '../core/AgentState';
|
||||
import type { LLMProvider } from '../llm/LLMProvider';
|
||||
import type { OpenAIToolDefinition } from '../tools/BaseTool';
|
||||
|
||||
export interface CompactProgress {
|
||||
chunkIndex: number;
|
||||
chunkCount: number;
|
||||
receivedTokenCount: number;
|
||||
}
|
||||
|
||||
export interface ConversationCompactorOptions {
|
||||
provider: LLMProvider;
|
||||
systemPrompt: string;
|
||||
tools?: OpenAIToolDefinition[];
|
||||
focus?: string;
|
||||
onProgress?: (progress: CompactProgress) => void;
|
||||
supplementalContext?: string;
|
||||
}
|
||||
|
||||
export interface ConversationCompactionResult {
|
||||
changed: boolean;
|
||||
compactedConversation: string;
|
||||
summary: string;
|
||||
tailStartIndex: number;
|
||||
}
|
||||
|
||||
const CHUNK_CHARACTER_BUDGET = 24_000;
|
||||
|
||||
function formatMessage(message: Message): string {
|
||||
const parts = [`[${message.role.toUpperCase()}]`];
|
||||
if (message.is_compacted_summary) {
|
||||
parts.push('[COMPACTED_SUMMARY]');
|
||||
}
|
||||
|
||||
if (message.content) {
|
||||
parts.push(message.content);
|
||||
}
|
||||
|
||||
if (message.tool_calls?.length) {
|
||||
for (const toolCall of message.tool_calls) {
|
||||
parts.push(
|
||||
`TOOL_CALL ${toolCall.function.name}: ${toolCall.function.arguments}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.tool_call_id) {
|
||||
parts.push(`TOOL_RESULT_FOR ${message.tool_call_id}`);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function splitIntoChunks(serializedMessages: string[]): string[] {
|
||||
const chunks: string[] = [];
|
||||
let currentChunk = '';
|
||||
|
||||
for (const serialized of serializedMessages) {
|
||||
if (!currentChunk) {
|
||||
currentChunk = serialized;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((currentChunk.length + serialized.length + 2) > CHUNK_CHARACTER_BUDGET) {
|
||||
chunks.push(currentChunk);
|
||||
currentChunk = serialized;
|
||||
continue;
|
||||
}
|
||||
|
||||
currentChunk += `\n\n${serialized}`;
|
||||
}
|
||||
|
||||
if (currentChunk) {
|
||||
chunks.push(currentChunk);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function buildCompactionUserPrompt(
|
||||
chunkText: string,
|
||||
chunkIndex: number,
|
||||
chunkCount: number,
|
||||
focus?: string,
|
||||
supplementalContext?: string,
|
||||
): string {
|
||||
const focusSection = focus?.trim()
|
||||
? `Focus instruction from the user: ${focus.trim()}\n\n`
|
||||
: '';
|
||||
const contextSection = supplementalContext?.trim()
|
||||
? `${supplementalContext.trim()}\n\n`
|
||||
: '';
|
||||
|
||||
return `${focusSection}${contextSection}Summarize this conversation history chunk for future continuation.
|
||||
|
||||
Preserve:
|
||||
- the active goal
|
||||
- accepted constraints and decisions
|
||||
- important tool results and errors
|
||||
- relevant project, track, region, and music context
|
||||
- unfinished work and next steps
|
||||
|
||||
Do not quote the full transcript. Produce a concise but durable handoff summary.
|
||||
|
||||
Chunk ${chunkIndex + 1} of ${chunkCount}:
|
||||
|
||||
${chunkText}`;
|
||||
}
|
||||
|
||||
export class ConversationCompactor {
|
||||
private readonly provider: LLMProvider;
|
||||
private readonly systemPrompt: string;
|
||||
private readonly tools: OpenAIToolDefinition[];
|
||||
private readonly focus?: string;
|
||||
private readonly onProgress?: (progress: CompactProgress) => void;
|
||||
private readonly supplementalContext?: string;
|
||||
|
||||
constructor(options: ConversationCompactorOptions) {
|
||||
this.provider = options.provider;
|
||||
this.systemPrompt = options.systemPrompt;
|
||||
this.tools = options.tools ?? [];
|
||||
this.focus = options.focus;
|
||||
this.onProgress = options.onProgress;
|
||||
this.supplementalContext = options.supplementalContext;
|
||||
}
|
||||
|
||||
async compact(messages: Message[], tailStartIndex: number): Promise<ConversationCompactionResult> {
|
||||
if (tailStartIndex <= 0 || tailStartIndex >= messages.length) {
|
||||
return {
|
||||
changed: false,
|
||||
compactedConversation: this.renderConversation(messages),
|
||||
summary: '',
|
||||
tailStartIndex,
|
||||
};
|
||||
}
|
||||
|
||||
const prefix = messages.slice(0, tailStartIndex);
|
||||
const serializedMessages = prefix.map(formatMessage);
|
||||
const chunks = splitIntoChunks(serializedMessages);
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return {
|
||||
changed: false,
|
||||
compactedConversation: this.renderConversation(messages),
|
||||
summary: '',
|
||||
tailStartIndex,
|
||||
};
|
||||
}
|
||||
|
||||
let summaries = await Promise.all(
|
||||
chunks.map((chunk, index) => this.summarizeChunk(chunk, index, chunks.length)),
|
||||
);
|
||||
|
||||
while (summaries.length > 1) {
|
||||
const mergedChunks = splitIntoChunks(summaries.map((summary, index) => `SUMMARY ${index + 1}\n${summary}`));
|
||||
summaries = await Promise.all(
|
||||
mergedChunks.map((chunk, index) => this.summarizeChunk(chunk, index, mergedChunks.length)),
|
||||
);
|
||||
}
|
||||
|
||||
const summary = `Compacted conversation summary:\n${summaries[0].trim()}`;
|
||||
const compactedConversation = this.renderConversation([
|
||||
{
|
||||
...messages[0],
|
||||
id: 'compacted-summary-preview',
|
||||
role: 'assistant',
|
||||
content: summary,
|
||||
is_compacted_summary: true,
|
||||
compact_trigger: 'manual',
|
||||
tool_calls: undefined,
|
||||
tool_call_id: undefined,
|
||||
},
|
||||
...messages.slice(tailStartIndex),
|
||||
]);
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
compactedConversation,
|
||||
summary,
|
||||
tailStartIndex,
|
||||
};
|
||||
}
|
||||
|
||||
renderConversation(messages: Message[]): string {
|
||||
return messages.map(formatMessage).join('\n\n');
|
||||
}
|
||||
|
||||
private async summarizeChunk(chunkText: string, chunkIndex: number, chunkCount: number): Promise<string> {
|
||||
const prompt = buildCompactionUserPrompt(
|
||||
chunkText,
|
||||
chunkIndex,
|
||||
chunkCount,
|
||||
this.focus,
|
||||
this.supplementalContext,
|
||||
);
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: `compact_user_${chunkIndex}`,
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
let receivedTokenCount = 0;
|
||||
let summary = '';
|
||||
|
||||
for await (const chunk of this.provider.generateStream(messages, this.systemPrompt, [])) {
|
||||
if (chunk.type === 'text') {
|
||||
summary += chunk.content;
|
||||
receivedTokenCount += 1;
|
||||
this.onProgress?.({
|
||||
chunkIndex,
|
||||
chunkCount,
|
||||
receivedTokenCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return summary.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AgentCore } from './AgentCore';
|
||||
import type { LLMProvider } from '../llm/LLMProvider';
|
||||
import type { Message, ToolCall } from './AgentState';
|
||||
import type { StreamChunk } from '../llm/StreamingTypes';
|
||||
import type { OpenAIToolDefinition } from '../tools/BaseTool';
|
||||
import { ReadMusicTool } from '../tools/ReadMusicTool';
|
||||
|
||||
const configState = new Map<string, unknown>([
|
||||
['general.agent_mode', 'regular'],
|
||||
['general.llm_provider', 'openai'],
|
||||
['general.auto_compact_threshold_percent', 90],
|
||||
]);
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: {
|
||||
getState: () => ({
|
||||
refreshProjectState: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./SystemPrompts', () => ({
|
||||
SystemPrompts: {
|
||||
getSystemPromptWithContext: vi.fn(async (templatePath?: string) => `system prompt:${templatePath ?? 'default'}`),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../core/config/ConfigManager', () => ({
|
||||
ConfigManager: {
|
||||
instance: () => ({
|
||||
getIsInitialized: () => true,
|
||||
initialize: vi.fn(async () => undefined),
|
||||
get: (key: string) => configState.get(key),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
class ScriptedProvider implements LLMProvider {
|
||||
public calls: Message[][] = [];
|
||||
public systemPrompts: Array<string | undefined> = [];
|
||||
public tools: OpenAIToolDefinition[][] = [];
|
||||
|
||||
constructor(private readonly scripts: StreamChunk[][]) {}
|
||||
|
||||
async *generateStream(
|
||||
messages: Message[],
|
||||
systemPrompt?: string,
|
||||
tools?: OpenAIToolDefinition[],
|
||||
): AsyncIterableIterator<StreamChunk> {
|
||||
this.calls.push(messages.map(message => ({ ...message })));
|
||||
this.systemPrompts.push(systemPrompt);
|
||||
this.tools.push(tools ?? []);
|
||||
const script = this.scripts.shift() ?? [{ type: 'done', content: '', finishReason: 'stop' }];
|
||||
for (const chunk of script) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeToolCall(name: string, args: Record<string, unknown>, id: string): ToolCall {
|
||||
return {
|
||||
id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name,
|
||||
arguments: JSON.stringify(args),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function collectChunks(input: string): Promise<StreamChunk[]> {
|
||||
const chunks: StreamChunk[] = [];
|
||||
for await (const chunk of AgentCore.instance().processUserInput(input)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
describe('AgentCore todo integration', () => {
|
||||
beforeEach(() => {
|
||||
configState.set('general.agent_mode', 'regular');
|
||||
configState.set('general.llm_provider', 'openai');
|
||||
configState.set('general.auto_compact_threshold_percent', 90);
|
||||
AgentCore.instance().clearConversation();
|
||||
AgentCore.instance().setLLMProvider(new ScriptedProvider([
|
||||
[{ type: 'done', content: '', finishReason: 'stop' }],
|
||||
]));
|
||||
});
|
||||
|
||||
it('updates todo state through the update_todo_list tool during the agent loop', async () => {
|
||||
const provider = new ScriptedProvider([
|
||||
[
|
||||
{
|
||||
type: 'tool_call',
|
||||
content: '',
|
||||
toolCall: makeToolCall('update_todo_list', {
|
||||
items: [
|
||||
{ id: '1', text: 'Read current music', status: 'completed' },
|
||||
{ id: '2', text: 'Write counter melody', status: 'in_progress' },
|
||||
],
|
||||
}, 'todo_1'),
|
||||
},
|
||||
{ type: 'done', content: '', finishReason: 'tool_calls' },
|
||||
],
|
||||
[
|
||||
{ type: 'text', content: 'Done' },
|
||||
{ type: 'done', content: '', finishReason: 'stop' },
|
||||
],
|
||||
]);
|
||||
AgentCore.instance().setLLMProvider(provider);
|
||||
|
||||
await collectChunks('Plan and update the region in multiple steps.');
|
||||
|
||||
expect(AgentCore.instance().getAgentState().getTodos()).toEqual([
|
||||
expect.objectContaining({ id: '1', text: 'Read current music', status: 'completed' }),
|
||||
expect.objectContaining({ id: '2', text: 'Write counter melody', status: 'in_progress' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('injects a hidden reminder after tool work goes stale with an active checklist', async () => {
|
||||
AgentCore.instance().getAgentState().setTodos([
|
||||
{ id: '1', text: 'Analyze melody', status: 'in_progress', updatedAt: 1 },
|
||||
]);
|
||||
const provider = new ScriptedProvider([
|
||||
[
|
||||
{ type: 'tool_call', content: '', toolCall: makeToolCall('unknown_tool', {}, 'tool_1') },
|
||||
{ type: 'done', content: '', finishReason: 'tool_calls' },
|
||||
],
|
||||
[
|
||||
{ type: 'tool_call', content: '', toolCall: makeToolCall('unknown_tool', {}, 'tool_2') },
|
||||
{ type: 'done', content: '', finishReason: 'tool_calls' },
|
||||
],
|
||||
[
|
||||
{ type: 'text', content: 'Final reply' },
|
||||
{ type: 'done', content: '', finishReason: 'stop' },
|
||||
],
|
||||
]);
|
||||
AgentCore.instance().setLLMProvider(provider);
|
||||
|
||||
await collectChunks('Please analyze and revise this passage.');
|
||||
|
||||
expect(provider.calls[2][provider.calls[2].length - 1]?.content).toContain('Keep the task list current');
|
||||
});
|
||||
|
||||
it('does not inject the reminder for a simple one-shot turn without todos', async () => {
|
||||
const provider = new ScriptedProvider([
|
||||
[
|
||||
{ type: 'tool_call', content: '', toolCall: makeToolCall('unknown_tool', {}, 'tool_1') },
|
||||
{ type: 'done', content: '', finishReason: 'tool_calls' },
|
||||
],
|
||||
[
|
||||
{ type: 'text', content: 'Final reply' },
|
||||
{ type: 'done', content: '', finishReason: 'stop' },
|
||||
],
|
||||
]);
|
||||
AgentCore.instance().setLLMProvider(provider);
|
||||
|
||||
await collectChunks('Read the current region.');
|
||||
|
||||
expect(provider.calls[1][provider.calls[1].length - 1]?.content).not.toContain('Keep the task list current');
|
||||
});
|
||||
|
||||
it('requests approval for non-read-only tools and continues after allow', async () => {
|
||||
const provider = new ScriptedProvider([
|
||||
[
|
||||
{ type: 'tool_call', content: '', toolCall: makeToolCall('add_notes', { notes: [{ pitch: 'C4', start: 0, length: 1 }] }, 'tool_1') },
|
||||
{ type: 'done', content: '', finishReason: 'tool_calls' },
|
||||
],
|
||||
[
|
||||
{ type: 'text', content: 'Completed' },
|
||||
{ type: 'done', content: '', finishReason: 'stop' },
|
||||
],
|
||||
]);
|
||||
AgentCore.instance().setLLMProvider(provider);
|
||||
|
||||
const requestToolApproval = vi.fn(async () => 'allow' as const);
|
||||
const chunks: StreamChunk[] = [];
|
||||
for await (const chunk of AgentCore.instance().processUserInput('Write notes', { requestToolApproval })) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
expect(requestToolApproval).toHaveBeenCalledTimes(1);
|
||||
expect(chunks.some(chunk => chunk.type === 'tool_result' && chunk.toolResult?.name === 'add_notes')).toBe(true);
|
||||
expect(chunks.at(-1)?.type).toBe('done');
|
||||
});
|
||||
|
||||
it('records denied tool execution and stops the turn after deny', async () => {
|
||||
const provider = new ScriptedProvider([
|
||||
[
|
||||
{ type: 'tool_call', content: '', toolCall: makeToolCall('add_notes', { notes: [{ pitch: 'C4', start: 0, length: 1 }] }, 'tool_1') },
|
||||
{ type: 'done', content: '', finishReason: 'tool_calls' },
|
||||
],
|
||||
[
|
||||
{ type: 'text', content: 'Should not run' },
|
||||
{ type: 'done', content: '', finishReason: 'stop' },
|
||||
],
|
||||
]);
|
||||
AgentCore.instance().setLLMProvider(provider);
|
||||
|
||||
const chunks: StreamChunk[] = [];
|
||||
for await (const chunk of AgentCore.instance().processUserInput('Write notes', {
|
||||
requestToolApproval: async () => 'deny',
|
||||
})) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const deniedChunk = chunks.find(chunk => chunk.type === 'tool_result' && chunk.toolResult?.name === 'add_notes');
|
||||
expect(deniedChunk?.toolResult?.denied).toBe(true);
|
||||
expect(deniedChunk?.toolResult?.result).toBe('Execution was denied by the user.');
|
||||
expect(provider.calls).toHaveLength(1);
|
||||
expect(AgentCore.instance().getAgentState().getMessages().at(-1)?.role).toBe('tool');
|
||||
});
|
||||
|
||||
it('restores a saved conversation document into the agent state', () => {
|
||||
AgentCore.instance().restoreConversation({
|
||||
version: 1,
|
||||
conversationId: 'conv_saved',
|
||||
continuationState: {
|
||||
messages: [
|
||||
{ id: 'm2', role: 'assistant', content: 'summary', timestamp: 2 },
|
||||
],
|
||||
todos: [
|
||||
{ id: 'todo-1', text: 'Continue work', status: 'in_progress', updatedAt: 3 },
|
||||
],
|
||||
},
|
||||
fullHistory: {
|
||||
messages: [
|
||||
{ id: 'm1', role: 'user', content: 'prompt', timestamp: 1 },
|
||||
{ id: 'm2', role: 'assistant', content: 'summary', timestamp: 2 },
|
||||
],
|
||||
},
|
||||
displayTranscript: [],
|
||||
});
|
||||
|
||||
expect(AgentCore.instance().getAgentState().getConversationId()).toBe('conv_saved');
|
||||
expect(AgentCore.instance().getAgentState().getMessages()).toHaveLength(1);
|
||||
expect(AgentCore.instance().getAgentState().getFullMessages()).toHaveLength(2);
|
||||
expect(AgentCore.instance().getAgentState().getTodos()).toEqual([
|
||||
{ id: 'todo-1', text: 'Continue work', status: 'in_progress', updatedAt: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the regular system prompt in regular mode', async () => {
|
||||
configState.set('general.agent_mode', 'regular');
|
||||
const provider = new ScriptedProvider([
|
||||
[{ type: 'done', content: '', finishReason: 'stop' }],
|
||||
]);
|
||||
AgentCore.instance().setLLMProvider(provider);
|
||||
|
||||
await collectChunks('Read the current region.');
|
||||
|
||||
expect(provider.systemPrompts[0]).toBe('system prompt:prompts/system.md');
|
||||
});
|
||||
|
||||
it('exposes the new track management tools in regular mode', async () => {
|
||||
configState.set('general.agent_mode', 'regular');
|
||||
const provider = new ScriptedProvider([
|
||||
[{ type: 'done', content: '', finishReason: 'stop' }],
|
||||
]);
|
||||
AgentCore.instance().setLLMProvider(provider);
|
||||
|
||||
await collectChunks('Inspect available tools.');
|
||||
|
||||
const toolNames = provider.tools[0].map(tool => tool.function.name);
|
||||
expect(toolNames).toContain('list_all_available_instruments');
|
||||
expect(toolNames).toContain('create_new_track');
|
||||
expect(toolNames).toContain('update_track');
|
||||
expect(toolNames).toContain('write_chord_progression');
|
||||
});
|
||||
|
||||
it('uses the compact system prompt in efficient mode', async () => {
|
||||
configState.set('general.agent_mode', 'efficient');
|
||||
const provider = new ScriptedProvider([
|
||||
[{ type: 'done', content: '', finishReason: 'stop' }],
|
||||
]);
|
||||
AgentCore.instance().setLLMProvider(provider);
|
||||
|
||||
await collectChunks('Read the current region.');
|
||||
|
||||
expect(provider.systemPrompts[0]).toBe('system prompt:prompts/system_compact.md');
|
||||
});
|
||||
|
||||
it('forces efficient mode when the local browser provider is selected', async () => {
|
||||
configState.set('general.agent_mode', 'regular');
|
||||
configState.set('general.llm_provider', 'local_browser');
|
||||
const provider = new ScriptedProvider([
|
||||
[{ type: 'done', content: '', finishReason: 'stop' }],
|
||||
]);
|
||||
AgentCore.instance().setLLMProvider(provider);
|
||||
|
||||
await collectChunks('Read the current region.');
|
||||
|
||||
expect(provider.systemPrompts[0]).toBe('system prompt:prompts/system_compact.md');
|
||||
});
|
||||
|
||||
it('filters tool definitions by the active agent mode', async () => {
|
||||
configState.set('general.agent_mode', 'efficient');
|
||||
const provider = new ScriptedProvider([
|
||||
[{ type: 'done', content: '', finishReason: 'stop' }],
|
||||
]);
|
||||
AgentCore.instance().setLLMProvider(provider);
|
||||
|
||||
const spy = vi.spyOn(ReadMusicTool.prototype, 'isAvailableInEfficientMode')
|
||||
.mockReturnValue(false);
|
||||
|
||||
await collectChunks('Read the current region.');
|
||||
|
||||
const toolNames = provider.tools[0].map(tool => tool.function.name);
|
||||
expect(toolNames).not.toContain('read_music');
|
||||
expect(toolNames).not.toContain('list_all_available_instruments');
|
||||
expect(toolNames).not.toContain('create_new_track');
|
||||
expect(toolNames).not.toContain('update_track');
|
||||
expect(toolNames).not.toContain('write_chord_progression');
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects tool calls for tools unavailable in the active mode', async () => {
|
||||
configState.set('general.agent_mode', 'efficient');
|
||||
const availabilitySpy = vi.spyOn(ReadMusicTool.prototype, 'isAvailableInEfficientMode')
|
||||
.mockReturnValue(false);
|
||||
const provider = new ScriptedProvider([
|
||||
[
|
||||
{ type: 'tool_call', content: '', toolCall: makeToolCall('read_music', {}, 'tool_1') },
|
||||
{ type: 'done', content: '', finishReason: 'tool_calls' },
|
||||
],
|
||||
[
|
||||
{ type: 'text', content: 'Done' },
|
||||
{ type: 'done', content: '', finishReason: 'stop' },
|
||||
],
|
||||
]);
|
||||
AgentCore.instance().setLLMProvider(provider);
|
||||
|
||||
const chunks = await collectChunks('Read the current region.');
|
||||
const toolResultChunk = chunks.find(chunk => chunk.type === 'tool_result' && chunk.toolResult?.name === 'read_music');
|
||||
|
||||
expect(toolResultChunk?.toolResult?.success).toBe(false);
|
||||
expect(toolResultChunk?.toolResult?.result).toBe("Tool 'read_music' is not available in Efficient Mode.");
|
||||
availabilitySpy.mockRestore();
|
||||
});
|
||||
});
|
||||
+319
-17
@@ -1,11 +1,32 @@
|
||||
import type { LLMProvider } from '../llm/LLMProvider';
|
||||
import { AgentState } from './AgentState';
|
||||
import { SystemPrompts } from './SystemPrompts';
|
||||
import { AVAILABLE_TOOLS } from '../tools';
|
||||
import { AVAILABLE_TOOLS, createToolInstance } from '../tools';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import type { StreamChunk } from '../llm/StreamingTypes';
|
||||
import type { StreamChunk, ToolApprovalDecision } from '../llm/StreamingTypes';
|
||||
import type { ToolCall } from './AgentState';
|
||||
import type { OpenAIToolDefinition } from '../tools/BaseTool';
|
||||
import type { BaseTool, OpenAIToolDefinition } from '../tools/BaseTool';
|
||||
import { ConversationCompactor, type CompactProgress } from '../compact/ConversationCompactor';
|
||||
import { ConfigManager } from '../../core/config/ConfigManager';
|
||||
import { buildTodoContext } from './todo';
|
||||
import type { SavedConversationDocument } from '../../types/conversationTypes';
|
||||
import type { AgentMode } from '../../util/agentMode';
|
||||
import { getEffectiveAgentMode, getSystemPromptPathForAgentMode } from '../../util/agentMode';
|
||||
|
||||
export interface CompactConversationOptions {
|
||||
trigger: 'manual' | 'auto';
|
||||
focus?: string;
|
||||
onProgress?: (progress: CompactProgress) => void;
|
||||
}
|
||||
|
||||
export interface CompactConversationResult {
|
||||
changed: boolean;
|
||||
compactedConversation: string;
|
||||
}
|
||||
|
||||
export interface ProcessUserInputOptions {
|
||||
requestToolApproval?: (toolCall: ToolCall) => Promise<ToolApprovalDecision>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main orchestrator for the AI agent system.
|
||||
@@ -13,11 +34,16 @@ import type { OpenAIToolDefinition } from '../tools/BaseTool';
|
||||
*/
|
||||
export class AgentCore {
|
||||
private static _instance: AgentCore | null = null;
|
||||
private static readonly TODO_TOOL_NAME = 'update_todo_list';
|
||||
private static readonly TODO_REMINDER = '<reminder>Keep the task list current. Use update_todo_list for multi-step work, mark one item in_progress before major tool work, and complete items as you finish them.</reminder>';
|
||||
|
||||
private llmProvider: LLMProvider | null = null;
|
||||
private agentState: AgentState;
|
||||
private currentUserMessageId: string | null = null;
|
||||
private currentAssistantMessageId: string | null = null;
|
||||
private todoToolCyclesSinceUpdate = 0;
|
||||
private remindAboutTodosOnNextLoop = false;
|
||||
private currentTurnLikelyMultiStep = false;
|
||||
|
||||
private constructor() {
|
||||
this.agentState = new AgentState();
|
||||
@@ -45,27 +71,56 @@ export class AgentCore {
|
||||
/**
|
||||
* Get OpenAI tool definitions for all available tools
|
||||
*/
|
||||
private getToolDefinitions(): OpenAIToolDefinition[] {
|
||||
private getToolDefinitions(agentMode: AgentMode): OpenAIToolDefinition[] {
|
||||
return Object.values(AVAILABLE_TOOLS).map(ToolClass => {
|
||||
const tool = new ToolClass();
|
||||
return tool.getDefinition();
|
||||
});
|
||||
return this.isToolAvailableInMode(tool, agentMode) ? tool.getDefinition() : null;
|
||||
}).filter((tool): tool is OpenAIToolDefinition => tool !== null);
|
||||
}
|
||||
|
||||
private async getSystemPrompt(templatePath?: string): Promise<string> {
|
||||
return SystemPrompts.getSystemPromptWithContext(templatePath);
|
||||
}
|
||||
|
||||
private getEffectiveAgentMode(): AgentMode {
|
||||
return getEffectiveAgentMode(ConfigManager.instance());
|
||||
}
|
||||
|
||||
private getSystemPromptTemplatePath(agentMode: AgentMode): string {
|
||||
return getSystemPromptPathForAgentMode(agentMode);
|
||||
}
|
||||
|
||||
private isToolAvailableInMode(toolInstance: BaseTool | null, agentMode: AgentMode): boolean {
|
||||
if (!toolInstance) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return agentMode === 'efficient'
|
||||
? toolInstance.isAvailableInEfficientMode()
|
||||
: toolInstance.isAvailableInRegularMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single tool call and return the result
|
||||
*/
|
||||
private async executeTool(toolCall: ToolCall): Promise<{ success: boolean; result: string }> {
|
||||
const toolName = toolCall.function.name;
|
||||
const ToolClass = AVAILABLE_TOOLS[toolName as keyof typeof AVAILABLE_TOOLS];
|
||||
private async executeTool(
|
||||
toolCall: ToolCall,
|
||||
agentMode: AgentMode,
|
||||
): Promise<{ success: boolean; result: string }> {
|
||||
const toolInstance = createToolInstance(toolCall.function.name);
|
||||
if (!toolInstance) {
|
||||
return { success: false, result: `Unknown tool: ${toolCall.function.name}` };
|
||||
}
|
||||
|
||||
if (!ToolClass) {
|
||||
return { success: false, result: `Unknown tool: ${toolName}` };
|
||||
if (!this.isToolAvailableInMode(toolInstance, agentMode)) {
|
||||
return {
|
||||
success: false,
|
||||
result: `Tool '${toolCall.function.name}' is not available in ${agentMode === 'efficient' ? 'Efficient Mode' : 'Regular Mode'}.`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const params = JSON.parse(toolCall.function.arguments);
|
||||
const toolInstance = new ToolClass();
|
||||
const result = await toolInstance.execute(params);
|
||||
|
||||
// Sync UI state after successful tool execution
|
||||
@@ -84,18 +139,23 @@ export class AgentCore {
|
||||
* Handles the full agentic loop internally: if the LLM returns tool_calls,
|
||||
* execute them and feed results back until the LLM produces a final text response.
|
||||
*/
|
||||
async *processUserInput(userInput: string): AsyncIterableIterator<StreamChunk> {
|
||||
async *processUserInput(
|
||||
userInput: string,
|
||||
options?: ProcessUserInputOptions,
|
||||
): AsyncIterableIterator<StreamChunk> {
|
||||
if (!this.llmProvider) {
|
||||
throw new Error('No LLM provider configured');
|
||||
}
|
||||
|
||||
// Add user message to state
|
||||
this.currentUserMessageId = this.agentState.addMessage('user', userInput);
|
||||
this.currentTurnLikelyMultiStep = this.isLikelyMultiStepTask(userInput);
|
||||
|
||||
const agentMode = this.getEffectiveAgentMode();
|
||||
const systemPrompt = await SystemPrompts.getSystemPromptWithContext(
|
||||
this.llmProvider.getPreferredSystemPromptPath?.(),
|
||||
this.getSystemPromptTemplatePath(agentMode),
|
||||
);
|
||||
const tools = this.getToolDefinitions();
|
||||
const tools = this.getToolDefinitions(agentMode);
|
||||
|
||||
try {
|
||||
// Agentic loop: stream → check for tool calls → execute → repeat
|
||||
@@ -103,6 +163,7 @@ export class AgentCore {
|
||||
|
||||
while (continueLoop) {
|
||||
const conversationHistory = this.agentState.getMessages();
|
||||
const turnMessages = this.buildLoopMessages(conversationHistory);
|
||||
|
||||
// Pre-add an empty assistant message that we'll update as we stream
|
||||
this.currentAssistantMessageId = this.agentState.addMessage('assistant', '');
|
||||
@@ -112,7 +173,7 @@ export class AgentCore {
|
||||
let finishReason = 'stop';
|
||||
let performanceInfo: StreamChunk['performanceInfo'];
|
||||
|
||||
for await (const chunk of this.llmProvider.generateStream(conversationHistory, systemPrompt, tools)) {
|
||||
for await (const chunk of this.llmProvider.generateStream(turnMessages, systemPrompt, tools)) {
|
||||
if (chunk.type === 'text') {
|
||||
assistantTextContent += chunk.content;
|
||||
this.agentState.updateMessage(this.currentAssistantMessageId, assistantTextContent);
|
||||
@@ -126,6 +187,8 @@ export class AgentCore {
|
||||
}
|
||||
|
||||
if (finishReason === 'tool_calls' && accumulatedToolCalls.length > 0) {
|
||||
this.updateTodoReminderState(accumulatedToolCalls);
|
||||
|
||||
// Update assistant message with tool calls
|
||||
this.agentState.updateMessage(
|
||||
this.currentAssistantMessageId,
|
||||
@@ -138,7 +201,16 @@ export class AgentCore {
|
||||
// Notify UI about the tool call
|
||||
yield { type: 'tool_call', content: '', toolCall };
|
||||
|
||||
const result = await this.executeTool(toolCall);
|
||||
let denied = false;
|
||||
const toolInstance = createToolInstance(toolCall.function.name);
|
||||
if (toolInstance && !toolInstance.isReadOnlyTool() && options?.requestToolApproval) {
|
||||
const approvalDecision = await options.requestToolApproval(toolCall);
|
||||
denied = approvalDecision === 'deny';
|
||||
}
|
||||
|
||||
const result = denied
|
||||
? { success: false, result: 'Execution was denied by the user.' }
|
||||
: await this.executeTool(toolCall, agentMode);
|
||||
|
||||
// Add tool result message to conversation history
|
||||
this.agentState.addMessage('tool', JSON.stringify(result), {
|
||||
@@ -150,11 +222,18 @@ export class AgentCore {
|
||||
type: 'tool_result',
|
||||
content: '',
|
||||
toolResult: {
|
||||
toolCallId: toolCall.id,
|
||||
name: toolCall.function.name,
|
||||
success: result.success,
|
||||
result: result.result,
|
||||
denied,
|
||||
},
|
||||
};
|
||||
|
||||
if (denied) {
|
||||
continueLoop = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear assistant message ID before next iteration creates a new one
|
||||
@@ -171,6 +250,10 @@ export class AgentCore {
|
||||
} finally {
|
||||
this.currentUserMessageId = null;
|
||||
this.currentAssistantMessageId = null;
|
||||
this.currentTurnLikelyMultiStep = false;
|
||||
if (this.agentState.getTodos().length === 0) {
|
||||
this.remindAboutTodosOnNextLoop = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +288,133 @@ export class AgentCore {
|
||||
|
||||
clearConversation(): void {
|
||||
this.agentState.clearMessages();
|
||||
this.todoToolCyclesSinceUpdate = 0;
|
||||
this.remindAboutTodosOnNextLoop = false;
|
||||
this.currentTurnLikelyMultiStep = false;
|
||||
}
|
||||
|
||||
startNewConversation(): void {
|
||||
this.agentState.resetConversation();
|
||||
this.todoToolCyclesSinceUpdate = 0;
|
||||
this.remindAboutTodosOnNextLoop = false;
|
||||
this.currentTurnLikelyMultiStep = false;
|
||||
this.currentUserMessageId = null;
|
||||
this.currentAssistantMessageId = null;
|
||||
}
|
||||
|
||||
restoreConversation(document: SavedConversationDocument): void {
|
||||
this.agentState.replaceConversationState({
|
||||
conversationId: document.conversationId,
|
||||
messages: document.continuationState.messages,
|
||||
fullMessages: document.fullHistory.messages,
|
||||
todos: document.continuationState.todos,
|
||||
});
|
||||
this.todoToolCyclesSinceUpdate = 0;
|
||||
this.remindAboutTodosOnNextLoop = false;
|
||||
this.currentTurnLikelyMultiStep = false;
|
||||
this.currentUserMessageId = null;
|
||||
this.currentAssistantMessageId = null;
|
||||
}
|
||||
|
||||
async shouldCompactBeforeNextTurn(userInput: string): Promise<boolean> {
|
||||
if (!this.llmProvider?.estimateHistoryTokens || !this.llmProvider.getContextWindow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const contextWindow = this.llmProvider.getContextWindow();
|
||||
if (!contextWindow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const agentMode = this.getEffectiveAgentMode();
|
||||
const tools = this.getToolDefinitions(agentMode);
|
||||
const systemPrompt = await this.getSystemPrompt(
|
||||
this.getSystemPromptTemplatePath(agentMode),
|
||||
);
|
||||
const thresholdPercent = await this.getAutoCompactThresholdPercent();
|
||||
const reservedOutputTokens = this.llmProvider.getReservedOutputTokens?.() ?? 4096;
|
||||
const hypotheticalMessages = [
|
||||
...this.agentState.getMessages(),
|
||||
{
|
||||
id: `preflight_${Date.now()}`,
|
||||
role: 'user' as const,
|
||||
content: userInput,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
const estimatedTokens = await this.llmProvider.estimateHistoryTokens(
|
||||
hypotheticalMessages,
|
||||
systemPrompt,
|
||||
tools,
|
||||
);
|
||||
const thresholdTokens = Math.floor(contextWindow * (thresholdPercent / 100));
|
||||
|
||||
return (estimatedTokens + reservedOutputTokens) >= thresholdTokens;
|
||||
}
|
||||
|
||||
async compactConversation(options: CompactConversationOptions): Promise<CompactConversationResult> {
|
||||
if (!this.llmProvider) {
|
||||
throw new Error('No LLM provider configured');
|
||||
}
|
||||
|
||||
const messages = this.agentState.getMessages();
|
||||
if (messages.length < 2) {
|
||||
return {
|
||||
changed: false,
|
||||
compactedConversation: messages.map(message => message.content ?? '').join('\n\n'),
|
||||
};
|
||||
}
|
||||
|
||||
const tailStartIndex = this.agentState.findRecentTailStartIndex();
|
||||
if (tailStartIndex <= 0 || tailStartIndex >= messages.length) {
|
||||
return {
|
||||
changed: false,
|
||||
compactedConversation: messages.map(message => message.content ?? '').join('\n\n'),
|
||||
};
|
||||
}
|
||||
|
||||
const compactionPrompt = await this.getSystemPrompt('prompts/system_compaction.md');
|
||||
const compactor = new ConversationCompactor({
|
||||
provider: this.llmProvider,
|
||||
systemPrompt: compactionPrompt,
|
||||
tools: this.getToolDefinitions(this.getEffectiveAgentMode()),
|
||||
focus: options.focus,
|
||||
onProgress: options.onProgress,
|
||||
supplementalContext: buildTodoContext(this.agentState.getTodos()),
|
||||
});
|
||||
const result = await compactor.compact(messages, tailStartIndex);
|
||||
if (!result.changed) {
|
||||
return {
|
||||
changed: false,
|
||||
compactedConversation: result.compactedConversation,
|
||||
};
|
||||
}
|
||||
|
||||
const nextMessages = this.agentState.createCompactedHistory(
|
||||
result.summary,
|
||||
result.tailStartIndex,
|
||||
options.trigger,
|
||||
);
|
||||
this.agentState.replaceMessages(nextMessages);
|
||||
console.log('------------ COMPACTED CONVERSATION ------------');
|
||||
console.log(result.compactedConversation);
|
||||
console.log('------------------------------------------------');
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
compactedConversation: result.compactedConversation,
|
||||
};
|
||||
}
|
||||
|
||||
async retryAfterCompaction(
|
||||
userInput: string,
|
||||
options: Omit<CompactConversationOptions, 'trigger'> = {},
|
||||
): Promise<CompactConversationResult> {
|
||||
return this.compactConversation({
|
||||
trigger: 'auto',
|
||||
focus: options.focus,
|
||||
onProgress: options.onProgress,
|
||||
});
|
||||
}
|
||||
|
||||
getIsWorkingOnTask(): boolean {
|
||||
@@ -214,4 +424,96 @@ export class AgentCore {
|
||||
setIsWorkingOnTask(isWorking: boolean): void {
|
||||
this.agentState.setIsWorkingOnTask(isWorking);
|
||||
}
|
||||
|
||||
private async getAutoCompactThresholdPercent(): Promise<80 | 90 | 95> {
|
||||
try {
|
||||
const configManager = ConfigManager.instance();
|
||||
if (!configManager.getIsInitialized()) {
|
||||
await configManager.initialize();
|
||||
}
|
||||
|
||||
const configured = Number(configManager.get('general.auto_compact_threshold_percent'));
|
||||
if (configured === 80 || configured === 95) {
|
||||
return configured;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load auto-compact threshold, using default.', error);
|
||||
}
|
||||
|
||||
return 90;
|
||||
}
|
||||
|
||||
private buildLoopMessages(conversationHistory: ReturnType<AgentState['getMessages']>): ReturnType<AgentState['getMessages']> {
|
||||
if (!this.shouldInjectTodoReminder()) {
|
||||
return conversationHistory;
|
||||
}
|
||||
|
||||
return [
|
||||
...conversationHistory,
|
||||
{
|
||||
id: `todo_reminder_${Date.now()}`,
|
||||
role: 'user',
|
||||
content: AgentCore.TODO_REMINDER,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private shouldInjectTodoReminder(): boolean {
|
||||
const hasTodos = this.agentState.getTodos().length > 0;
|
||||
return (hasTodos && this.todoToolCyclesSinceUpdate >= 2)
|
||||
|| (!hasTodos && this.remindAboutTodosOnNextLoop && this.currentTurnLikelyMultiStep);
|
||||
}
|
||||
|
||||
private updateTodoReminderState(toolCalls: ToolCall[]): void {
|
||||
const usedTodoTool = toolCalls.some(toolCall => toolCall.function.name === AgentCore.TODO_TOOL_NAME);
|
||||
const hasNonTodoToolCall = toolCalls.some(toolCall => toolCall.function.name !== AgentCore.TODO_TOOL_NAME);
|
||||
const hasTodos = this.agentState.getTodos().length > 0;
|
||||
|
||||
if (usedTodoTool) {
|
||||
this.todoToolCyclesSinceUpdate = 0;
|
||||
this.remindAboutTodosOnNextLoop = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasNonTodoToolCall) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasTodos) {
|
||||
this.todoToolCyclesSinceUpdate += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentTurnLikelyMultiStep) {
|
||||
this.remindAboutTodosOnNextLoop = true;
|
||||
}
|
||||
}
|
||||
|
||||
private isLikelyMultiStepTask(userInput: string): boolean {
|
||||
const normalized = userInput.toLowerCase();
|
||||
if (/\n\s*[-*]\s|\n\s*\d+\.\s/.test(userInput)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const coordinationKeywords = [
|
||||
'plan',
|
||||
'analyze',
|
||||
'compare',
|
||||
'design',
|
||||
'implement',
|
||||
'refactor',
|
||||
'fix',
|
||||
'update',
|
||||
'multi-step',
|
||||
'todo',
|
||||
'checklist',
|
||||
];
|
||||
|
||||
if (coordinationKeywords.some(keyword => normalized.includes(keyword))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return userInput.length >= 120 && /\band\b/.test(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AgentState } from './AgentState';
|
||||
import type { TodoItem } from './todo';
|
||||
|
||||
describe('AgentState compaction helpers', () => {
|
||||
it('preserves the most recent exchange block as the raw tail', () => {
|
||||
const state = new AgentState('conv_test');
|
||||
state.addMessage('user', 'first');
|
||||
state.addMessage('assistant', 'first reply');
|
||||
state.addMessage('user', 'second');
|
||||
state.addMessage('assistant', 'second reply');
|
||||
|
||||
expect(state.findRecentTailStartIndex()).toBe(2);
|
||||
});
|
||||
|
||||
it('creates compacted history with a synthetic summary message', () => {
|
||||
const state = new AgentState('conv_test');
|
||||
state.addMessage('user', 'first');
|
||||
state.addMessage('assistant', 'first reply');
|
||||
state.addMessage('user', 'second');
|
||||
state.addMessage('assistant', 'second reply');
|
||||
|
||||
const compacted = state.createCompactedHistory('summary', 2, 'manual');
|
||||
|
||||
expect(compacted).toHaveLength(3);
|
||||
expect(compacted[0]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: 'summary',
|
||||
is_compacted_summary: true,
|
||||
compact_trigger: 'manual',
|
||||
});
|
||||
expect(compacted[1].content).toBe('second');
|
||||
expect(compacted[2].content).toBe('second reply');
|
||||
});
|
||||
|
||||
it('retains full history after current history is compacted', () => {
|
||||
const state = new AgentState('conv_test');
|
||||
state.addMessage('user', 'first');
|
||||
state.addMessage('assistant', 'first reply');
|
||||
state.addMessage('user', 'second');
|
||||
state.addMessage('assistant', 'second reply');
|
||||
|
||||
const compacted = state.createCompactedHistory('summary', 2, 'manual');
|
||||
state.replaceMessages(compacted);
|
||||
|
||||
expect(state.getMessages()).toHaveLength(3);
|
||||
expect(state.getFullMessages()).toHaveLength(4);
|
||||
expect(state.getFullMessages().map(message => message.content)).toEqual([
|
||||
'first',
|
||||
'first reply',
|
||||
'second',
|
||||
'second reply',
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears full history when conversation is cleared', () => {
|
||||
const state = new AgentState('conv_test');
|
||||
state.addMessage('user', 'first');
|
||||
state.addMessage('assistant', 'reply');
|
||||
|
||||
state.clearMessages();
|
||||
|
||||
expect(state.getMessages()).toHaveLength(0);
|
||||
expect(state.getFullMessages()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('stores, reads, and clears session-scoped todos', () => {
|
||||
const state = new AgentState('conv_test');
|
||||
const todos: TodoItem[] = [
|
||||
{ id: '1', text: 'Inspect region', status: 'completed', updatedAt: 1 },
|
||||
{ id: '2', text: 'Write notes', status: 'in_progress', activeText: 'Writing notes', updatedAt: 2 },
|
||||
];
|
||||
|
||||
state.setTodos(todos);
|
||||
|
||||
expect(state.getTodos()).toEqual(todos);
|
||||
|
||||
state.clearTodos();
|
||||
|
||||
expect(state.getTodos()).toEqual([]);
|
||||
});
|
||||
|
||||
it('retains todos when compacting message history', () => {
|
||||
const state = new AgentState('conv_test');
|
||||
state.addMessage('user', 'first');
|
||||
state.addMessage('assistant', 'first reply');
|
||||
state.addMessage('user', 'second');
|
||||
state.addMessage('assistant', 'second reply');
|
||||
state.setTodos([
|
||||
{ id: '1', text: 'Keep this task', status: 'in_progress', updatedAt: 1 },
|
||||
]);
|
||||
|
||||
const compacted = state.createCompactedHistory('summary', 2, 'manual');
|
||||
state.replaceMessages(compacted);
|
||||
|
||||
expect(state.getTodos()).toEqual([
|
||||
{ id: '1', text: 'Keep this task', status: 'in_progress', updatedAt: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears todos when the conversation is cleared', () => {
|
||||
const state = new AgentState('conv_test');
|
||||
state.addMessage('user', 'first');
|
||||
state.setTodos([
|
||||
{ id: '1', text: 'Temporary task', status: 'pending', updatedAt: 1 },
|
||||
]);
|
||||
|
||||
state.clearMessages();
|
||||
|
||||
expect(state.getTodos()).toEqual([]);
|
||||
});
|
||||
|
||||
it('restores full conversation state including full history and todos', () => {
|
||||
const state = new AgentState('conv_original');
|
||||
|
||||
state.replaceConversationState({
|
||||
conversationId: 'conv_restored',
|
||||
messages: [
|
||||
{ id: 'm2', role: 'assistant', content: 'summary', timestamp: 2, is_compacted_summary: true },
|
||||
],
|
||||
fullMessages: [
|
||||
{ id: 'm1', role: 'user', content: 'prompt', timestamp: 1 },
|
||||
{ id: 'm2', role: 'assistant', content: 'summary', timestamp: 2, is_compacted_summary: true },
|
||||
],
|
||||
todos: [
|
||||
{ id: 'todo-1', text: 'Resume work', status: 'in_progress', updatedAt: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(state.getConversationId()).toBe('conv_restored');
|
||||
expect(state.getMessages()).toHaveLength(1);
|
||||
expect(state.getFullMessages()).toHaveLength(2);
|
||||
expect(state.getTodos()).toEqual([
|
||||
{ id: 'todo-1', text: 'Resume work', status: 'in_progress', updatedAt: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('starts a fresh conversation with a new id when reset', () => {
|
||||
const state = new AgentState('conv_test');
|
||||
state.addMessage('user', 'first');
|
||||
|
||||
state.resetConversation('conv_new');
|
||||
|
||||
expect(state.getConversationId()).toBe('conv_new');
|
||||
expect(state.getMessages()).toEqual([]);
|
||||
expect(state.getFullMessages()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Manages the state of an agent conversation
|
||||
*/
|
||||
import type { TodoItem } from './todo';
|
||||
|
||||
/**
|
||||
* Tool call info attached to assistant messages (OpenAI function calling format)
|
||||
@@ -21,12 +22,17 @@ export interface Message {
|
||||
timestamp: number;
|
||||
tool_calls?: ToolCall[]; // present on assistant messages when LLM invokes tools
|
||||
tool_call_id?: string; // present on tool-result messages, links back to ToolCall.id
|
||||
is_compacted_summary?: boolean;
|
||||
compact_trigger?: 'manual' | 'auto';
|
||||
}
|
||||
|
||||
export class AgentState {
|
||||
private messages: Message[] = [];
|
||||
private fullMessages: Message[] = [];
|
||||
private todos: TodoItem[] = [];
|
||||
private conversationId: string;
|
||||
private isWorkingOnTask: boolean = false;
|
||||
private todoListeners: Set<() => void> = new Set();
|
||||
|
||||
constructor(conversationId?: string, isWorkingOnTask: boolean = false) {
|
||||
this.conversationId = conversationId || this.generateConversationId();
|
||||
@@ -39,7 +45,12 @@ export class AgentState {
|
||||
addMessage(
|
||||
role: 'user' | 'assistant' | 'tool',
|
||||
content: string | null,
|
||||
options?: { tool_calls?: ToolCall[]; tool_call_id?: string }
|
||||
options?: {
|
||||
tool_calls?: ToolCall[];
|
||||
tool_call_id?: string;
|
||||
is_compacted_summary?: boolean;
|
||||
compact_trigger?: 'manual' | 'auto';
|
||||
}
|
||||
): string {
|
||||
const message: Message = {
|
||||
id: this.generateMessageId(),
|
||||
@@ -48,9 +59,12 @@ export class AgentState {
|
||||
timestamp: Date.now(),
|
||||
...(options?.tool_calls ? { tool_calls: options.tool_calls } : {}),
|
||||
...(options?.tool_call_id ? { tool_call_id: options.tool_call_id } : {}),
|
||||
...(options?.is_compacted_summary ? { is_compacted_summary: true } : {}),
|
||||
...(options?.compact_trigger ? { compact_trigger: options.compact_trigger } : {}),
|
||||
};
|
||||
|
||||
this.messages.push(message);
|
||||
this.fullMessages.push({ ...message });
|
||||
return message.id;
|
||||
}
|
||||
|
||||
@@ -64,6 +78,17 @@ export class AgentState {
|
||||
if (options?.tool_calls) {
|
||||
this.messages[messageIndex].tool_calls = options.tool_calls;
|
||||
}
|
||||
}
|
||||
|
||||
const fullMessageIndex = this.fullMessages.findIndex(msg => msg.id === messageId);
|
||||
if (fullMessageIndex !== -1) {
|
||||
this.fullMessages[fullMessageIndex].content = content;
|
||||
if (options?.tool_calls) {
|
||||
this.fullMessages[fullMessageIndex].tool_calls = options.tool_calls;
|
||||
}
|
||||
}
|
||||
|
||||
if (messageIndex !== -1 || fullMessageIndex !== -1) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -76,6 +101,14 @@ export class AgentState {
|
||||
const messageIndex = this.messages.findIndex(msg => msg.id === messageId);
|
||||
if (messageIndex !== -1) {
|
||||
this.messages.splice(messageIndex, 1);
|
||||
}
|
||||
|
||||
const fullMessageIndex = this.fullMessages.findIndex(msg => msg.id === messageId);
|
||||
if (fullMessageIndex !== -1) {
|
||||
this.fullMessages.splice(fullMessageIndex, 1);
|
||||
}
|
||||
|
||||
if (messageIndex !== -1 || fullMessageIndex !== -1) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -86,6 +119,7 @@ export class AgentState {
|
||||
*/
|
||||
removeLastMessages(count: number): void {
|
||||
this.messages.splice(-count, count);
|
||||
this.fullMessages.splice(-count, count);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,6 +129,10 @@ export class AgentState {
|
||||
return [...this.messages];
|
||||
}
|
||||
|
||||
getFullMessages(): Message[] {
|
||||
return [...this.fullMessages];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the conversation ID
|
||||
*/
|
||||
@@ -107,6 +145,31 @@ export class AgentState {
|
||||
*/
|
||||
clearMessages(): void {
|
||||
this.messages = [];
|
||||
this.fullMessages = [];
|
||||
this.clearTodos();
|
||||
}
|
||||
|
||||
replaceMessages(messages: Message[]): void {
|
||||
this.messages = [...messages];
|
||||
}
|
||||
|
||||
replaceConversationState(state: {
|
||||
conversationId: string;
|
||||
messages: Message[];
|
||||
fullMessages: Message[];
|
||||
todos: TodoItem[];
|
||||
}): void {
|
||||
this.conversationId = state.conversationId;
|
||||
this.messages = state.messages.map(message => ({ ...message }));
|
||||
this.fullMessages = state.fullMessages.map(message => ({ ...message }));
|
||||
this.setTodos(state.todos);
|
||||
}
|
||||
|
||||
resetConversation(conversationId?: string): void {
|
||||
this.conversationId = conversationId || this.generateConversationId();
|
||||
this.messages = [];
|
||||
this.fullMessages = [];
|
||||
this.clearTodos();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,6 +179,29 @@ export class AgentState {
|
||||
return this.messages.slice(-count);
|
||||
}
|
||||
|
||||
findRecentTailStartIndex(): number {
|
||||
for (let i = this.messages.length - 1; i >= 0; i -= 1) {
|
||||
if (this.messages[i].role === 'user') {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return this.messages.length;
|
||||
}
|
||||
|
||||
createCompactedHistory(summary: string, tailStartIndex: number, trigger: 'manual' | 'auto'): Message[] {
|
||||
const preservedTail = this.messages.slice(Math.max(0, tailStartIndex));
|
||||
const summaryMessage: Message = {
|
||||
id: this.generateMessageId(),
|
||||
role: 'assistant',
|
||||
content: summary,
|
||||
timestamp: Date.now(),
|
||||
is_compacted_summary: true,
|
||||
compact_trigger: trigger,
|
||||
};
|
||||
|
||||
return [summaryMessage, ...preservedTail];
|
||||
}
|
||||
|
||||
private generateConversationId(): string {
|
||||
return `conv_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
|
||||
}
|
||||
@@ -132,4 +218,34 @@ export class AgentState {
|
||||
setIsWorkingOnTask(isWorkingOnTask: boolean): void {
|
||||
this.isWorkingOnTask = isWorkingOnTask;
|
||||
}
|
||||
|
||||
getTodos(): TodoItem[] {
|
||||
return this.todos.map(todo => ({ ...todo }));
|
||||
}
|
||||
|
||||
setTodos(todos: TodoItem[]): void {
|
||||
this.todos = todos.map(todo => ({ ...todo }));
|
||||
this.notifyTodoListeners();
|
||||
}
|
||||
|
||||
clearTodos(): void {
|
||||
if (this.todos.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.todos = [];
|
||||
this.notifyTodoListeners();
|
||||
}
|
||||
|
||||
subscribeTodoChanges(listener: () => void): () => void {
|
||||
this.todoListeners.add(listener);
|
||||
return () => {
|
||||
this.todoListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private notifyTodoListeners(): void {
|
||||
for (const listener of this.todoListeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SystemPrompts } from './SystemPrompts';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { KGGlobalTrack, GlobalTrackType } from '../../core/global-track/KGGlobalTrack';
|
||||
import { KGMarkerRegion } from '../../core/region/KGMarkerRegion';
|
||||
|
||||
const storeState = {
|
||||
activeRegionId: null as string | null,
|
||||
selectedRegionIds: [] as string[],
|
||||
};
|
||||
|
||||
const configState = new Map<string, unknown>();
|
||||
|
||||
const configManagerMock = {
|
||||
getIsInitialized: vi.fn(() => true),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn((key: string) => configState.get(key)),
|
||||
};
|
||||
|
||||
const coreState = {
|
||||
project: new KGProject(),
|
||||
selectedItems: [] as unknown[],
|
||||
};
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: {
|
||||
getState: () => storeState,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../core/config/ConfigManager', () => ({
|
||||
ConfigManager: {
|
||||
instance: () => configManagerMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../core/KGCore', () => ({
|
||||
KGCore: {
|
||||
instance: () => ({
|
||||
getCurrentProject: () => coreState.project,
|
||||
getSelectedItems: () => coreState.selectedItems,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
function createProject(): KGProject {
|
||||
const midiTrack = new KGMidiTrack('Piano', 1, 'acoustic_grand_piano');
|
||||
const regionA = new KGMidiRegion('midi-a', 'track-1', 0, 'A', 4, 8);
|
||||
const regionB = new KGMidiRegion('midi-b', 'track-1', 0, 'B', 20, 4);
|
||||
midiTrack.setRegions([regionA, regionB]);
|
||||
|
||||
const markerTrack = new KGGlobalTrack('global-marker', 0, GlobalTrackType.Marker, 'Marker');
|
||||
const markerRegion = new KGMarkerRegion('global-a', 'global-marker', 0, 'Marker A', 2, 2);
|
||||
markerTrack.setRegions([markerRegion]);
|
||||
|
||||
const project = new KGProject('Test Project');
|
||||
project.setTracks([midiTrack]);
|
||||
|
||||
const globalTracks = project.getGlobalTracks();
|
||||
const updatedGlobalTracks = globalTracks.map(track => (
|
||||
track.getType() === GlobalTrackType.Marker ? markerTrack : track
|
||||
));
|
||||
project.setGlobalTracks(updatedGlobalTracks);
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
describe('SystemPrompts', () => {
|
||||
beforeEach(() => {
|
||||
storeState.activeRegionId = null;
|
||||
storeState.selectedRegionIds = [];
|
||||
coreState.project = createProject();
|
||||
coreState.selectedItems = [];
|
||||
configState.clear();
|
||||
configManagerMock.getIsInitialized.mockReturnValue(true);
|
||||
configManagerMock.initialize.mockClear();
|
||||
configManagerMock.get.mockClear();
|
||||
SystemPrompts.clearCache();
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith('prompts/system.md')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => 'SYSTEM\n- BPM: {bpm}\n- Instrument: {track_instrument}',
|
||||
};
|
||||
}
|
||||
|
||||
if (url.endsWith('prompts/user_msg_appendix.md')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => 'APPENDIX\n{selected_music_range_section}',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => '',
|
||||
};
|
||||
}));
|
||||
});
|
||||
|
||||
it('uses loop bounds when loop mode is enabled', async () => {
|
||||
coreState.project.setIsLooping(true);
|
||||
coreState.project.setLoopingRange([2, 5]);
|
||||
|
||||
const prompt = await SystemPrompts.getSystemPromptWithContext();
|
||||
|
||||
expect(prompt).toContain('APPENDIX');
|
||||
expect(prompt).toContain('- Start Beat: 8');
|
||||
expect(prompt).toContain('- End Beat: 24');
|
||||
});
|
||||
|
||||
it('uses the earliest start and latest end across multiple selected regions', async () => {
|
||||
storeState.selectedRegionIds = ['midi-a', 'midi-b'];
|
||||
|
||||
const prompt = await SystemPrompts.getSystemPromptWithContext();
|
||||
|
||||
expect(prompt).toContain('- Start Beat: 4');
|
||||
expect(prompt).toContain('- End Beat: 24');
|
||||
});
|
||||
|
||||
it('includes mixed regular and global selected regions in the music range span', async () => {
|
||||
storeState.selectedRegionIds = ['midi-b', 'global-a'];
|
||||
|
||||
const prompt = await SystemPrompts.getSystemPromptWithContext();
|
||||
|
||||
expect(prompt).toContain('- Start Beat: 2');
|
||||
expect(prompt).toContain('- End Beat: 24');
|
||||
});
|
||||
|
||||
it('renders an explicit absence message when no music range is selected', async () => {
|
||||
const prompt = await SystemPrompts.getSystemPromptWithContext();
|
||||
|
||||
expect(prompt).toContain('- No selected music range.');
|
||||
});
|
||||
|
||||
it('applies appendix context to the system prompt template', async () => {
|
||||
const rendered = await SystemPrompts.getPromptWithContext('Range\n{selected_music_range_section}');
|
||||
|
||||
expect(rendered).toContain('Range');
|
||||
expect(rendered).toContain('- No selected music range.');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGRegion } from '../../core/region/KGRegion';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { KGTrack } from '../../core/track/KGTrack';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { ConfigManager } from '../../core/config/ConfigManager';
|
||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||
import {
|
||||
findRegionById,
|
||||
findRegularTrackByRegion,
|
||||
resolveSelectedMusicRangeContext,
|
||||
} from '../tools/toolTargeting';
|
||||
|
||||
/**
|
||||
* Context data structure for system prompt template replacement
|
||||
@@ -14,8 +18,7 @@ interface SystemPromptContext {
|
||||
time_signature: string;
|
||||
key_signature: string;
|
||||
track_instrument: string;
|
||||
current_region_start: number;
|
||||
current_region_end: number;
|
||||
selected_music_range_section: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,7 +31,10 @@ export class SystemPrompts {
|
||||
/**
|
||||
* Load the system prompt template from the public folder
|
||||
*/
|
||||
private static async loadTemplate(templatePath: string = 'prompts/system.md'): Promise<string> {
|
||||
private static async loadTemplate(
|
||||
templatePath: string = 'prompts/system.md',
|
||||
fallbackContent: string = this.FALLBACK_PROMPT,
|
||||
): Promise<string> {
|
||||
if (this.cachedTemplates.has(templatePath)) {
|
||||
return this.cachedTemplates.get(templatePath)!;
|
||||
}
|
||||
@@ -44,40 +50,10 @@ export class SystemPrompts {
|
||||
return template;
|
||||
} catch (error) {
|
||||
console.error('Failed to load system prompt template:', error);
|
||||
return this.FALLBACK_PROMPT;
|
||||
return fallbackContent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a region by ID across all tracks
|
||||
*/
|
||||
private static findRegionById(regionId: string): KGRegion | null {
|
||||
const core = KGCore.instance();
|
||||
const project = core.getCurrentProject();
|
||||
const tracks = project.getTracks();
|
||||
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === regionId);
|
||||
if (region) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find track that contains the given region
|
||||
*/
|
||||
private static findTrackByRegion(region: KGRegion): KGTrack | null {
|
||||
const core = KGCore.instance();
|
||||
const project = core.getCurrentProject();
|
||||
const tracks = project.getTracks();
|
||||
|
||||
return tracks.find(track => track.getRegions().includes(region)) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract current project context from KGCore
|
||||
*/
|
||||
@@ -98,9 +74,9 @@ export class SystemPrompts {
|
||||
// Step 1: Check if there's an active piano roll region
|
||||
const activeRegionId = this.getActiveRegionId();
|
||||
if (activeRegionId) {
|
||||
const activeRegion = this.findRegionById(activeRegionId);
|
||||
const activeRegion = findRegionById(activeRegionId);
|
||||
if (activeRegion) {
|
||||
const track = this.findTrackByRegion(activeRegion);
|
||||
const track = findRegularTrackByRegion(activeRegion);
|
||||
if (track && track instanceof KGMidiTrack) {
|
||||
trackInstrument = FLUIDR3_INSTRUMENT_MAP[track.getInstrument()].displayName;
|
||||
}
|
||||
@@ -108,10 +84,12 @@ export class SystemPrompts {
|
||||
} else {
|
||||
// Step 2: Check if user has selected region(s)
|
||||
const selectedItems = core.getSelectedItems();
|
||||
const selectedRegion = selectedItems.find(item => item instanceof KGRegion) as KGRegion;
|
||||
const selectedRegion = selectedItems.find((item): item is KGRegion => (
|
||||
item instanceof KGRegion && findRegularTrackByRegion(item) !== null
|
||||
)) ?? null;
|
||||
|
||||
if (selectedRegion) {
|
||||
const track = this.findTrackByRegion(selectedRegion);
|
||||
const track = findRegularTrackByRegion(selectedRegion);
|
||||
if (track && track instanceof KGMidiTrack) {
|
||||
trackInstrument = FLUIDR3_INSTRUMENT_MAP[track.getInstrument()].displayName;
|
||||
}
|
||||
@@ -132,54 +110,26 @@ export class SystemPrompts {
|
||||
/**
|
||||
* Get active region ID from project store
|
||||
*/
|
||||
private static getActiveRegionId(): string | null {
|
||||
private static getStoreState(): ReturnType<typeof useProjectStore.getState> | null {
|
||||
try {
|
||||
const store = useProjectStore.getState();
|
||||
return store.activeRegionId;
|
||||
return useProjectStore.getState();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract current region context with fallback logic
|
||||
* Get active region ID from project store
|
||||
*/
|
||||
private static extractRegionContext(): Partial<SystemPromptContext> {
|
||||
const core = KGCore.instance();
|
||||
const project = core.getCurrentProject();
|
||||
|
||||
// Step 1: Try active piano roll region
|
||||
const activeRegionId = this.getActiveRegionId();
|
||||
if (activeRegionId) {
|
||||
const activeRegion = this.findRegionById(activeRegionId);
|
||||
if (activeRegion) {
|
||||
return {
|
||||
current_region_start: activeRegion.getStartFromBeat(),
|
||||
current_region_end: activeRegion.getStartFromBeat() + activeRegion.getLength(),
|
||||
};
|
||||
}
|
||||
private static getActiveRegionId(): string | null {
|
||||
return this.getStoreState()?.activeRegionId ?? null;
|
||||
}
|
||||
|
||||
// Step 2: Try selected region
|
||||
const selectedItems = core.getSelectedItems();
|
||||
const selectedRegion = selectedItems.find(item => item instanceof KGRegion) as KGRegion;
|
||||
|
||||
if (selectedRegion) {
|
||||
return {
|
||||
current_region_start: selectedRegion.getStartFromBeat(),
|
||||
current_region_end: selectedRegion.getStartFromBeat() + selectedRegion.getLength(),
|
||||
};
|
||||
}
|
||||
|
||||
// Step 3: Fallback to project bounds
|
||||
const timeSignature = project.getTimeSignature();
|
||||
const beatsPerBar = timeSignature.numerator;
|
||||
const maxBars = project.getMaxBars();
|
||||
|
||||
return {
|
||||
current_region_start: 0,
|
||||
current_region_end: maxBars * beatsPerBar,
|
||||
};
|
||||
/**
|
||||
* Build the selected music range section for prompt templates.
|
||||
*/
|
||||
private static buildSelectedMusicRangeSection(): string {
|
||||
return resolveSelectedMusicRangeContext().section;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,15 +137,13 @@ export class SystemPrompts {
|
||||
*/
|
||||
private static getFullContext(): SystemPromptContext {
|
||||
const projectContext = this.extractProjectContext();
|
||||
const regionContext = this.extractRegionContext();
|
||||
|
||||
return {
|
||||
bpm: projectContext.bpm || 120,
|
||||
time_signature: projectContext.time_signature || '4/4',
|
||||
key_signature: projectContext.key_signature || 'C major',
|
||||
track_instrument: projectContext.track_instrument || 'Piano',
|
||||
current_region_start: regionContext.current_region_start || 0,
|
||||
current_region_end: regionContext.current_region_end || 32,
|
||||
bpm: projectContext.bpm ?? 120,
|
||||
time_signature: projectContext.time_signature ?? '4/4',
|
||||
key_signature: projectContext.key_signature ?? 'C major',
|
||||
track_instrument: projectContext.track_instrument ?? 'Piano',
|
||||
selected_music_range_section: this.buildSelectedMusicRangeSection(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -210,8 +158,7 @@ export class SystemPrompts {
|
||||
result = result.replace(/{time_signature}/g, context.time_signature);
|
||||
result = result.replace(/{key_signature}/g, context.key_signature);
|
||||
result = result.replace(/{track_instrument}/g, context.track_instrument);
|
||||
result = result.replace(/{current_region_start}/g, context.current_region_start.toString());
|
||||
result = result.replace(/{current_region_end}/g, context.current_region_end.toString());
|
||||
result = result.replace(/{selected_music_range_section}/g, context.selected_music_range_section);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -234,8 +181,17 @@ export class SystemPrompts {
|
||||
*/
|
||||
static async getSystemPromptWithContext(templatePath?: string): Promise<string> {
|
||||
try {
|
||||
const template = await this.loadTemplate(templatePath);
|
||||
let promptWithContext = await this.getPromptWithContext(template);
|
||||
const context = this.getFullContext();
|
||||
const [template, appendixTemplate] = await Promise.all([
|
||||
this.loadTemplate(templatePath),
|
||||
this.loadTemplate('prompts/user_msg_appendix.md', ''),
|
||||
]);
|
||||
let promptWithContext = this.replaceTemplateVariables(template, context);
|
||||
const appendixWithContext = this.replaceTemplateVariables(appendixTemplate, context);
|
||||
|
||||
if (appendixWithContext.trim().length > 0) {
|
||||
promptWithContext += `\n\n${appendixWithContext}`;
|
||||
}
|
||||
|
||||
// Append custom instructions from config if provided
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
export type TodoStatus = 'pending' | 'in_progress' | 'completed';
|
||||
|
||||
export interface TodoItem {
|
||||
id: string;
|
||||
text: string;
|
||||
status: TodoStatus;
|
||||
activeText?: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface TodoInputItem {
|
||||
id?: string;
|
||||
text: string;
|
||||
status: TodoStatus;
|
||||
activeText?: string;
|
||||
}
|
||||
|
||||
const TODO_MARKERS: Record<TodoStatus, string> = {
|
||||
pending: '[ ]',
|
||||
in_progress: '[>]',
|
||||
completed: '[x]',
|
||||
};
|
||||
|
||||
export function validateAndNormalizeTodos(items: TodoInputItem[], now: number = Date.now()): TodoItem[] {
|
||||
if (!Array.isArray(items)) {
|
||||
throw new Error('Todo items must be an array');
|
||||
}
|
||||
|
||||
if (items.length > 20) {
|
||||
throw new Error('Max 20 todo items allowed');
|
||||
}
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
let inProgressCount = 0;
|
||||
|
||||
return items.map((item, index) => {
|
||||
const id = String(item.id ?? index + 1).trim();
|
||||
const text = String(item.text ?? '').trim();
|
||||
const status = String(item.status ?? '').trim() as TodoStatus;
|
||||
const activeText = typeof item.activeText === 'string' ? item.activeText.trim() : undefined;
|
||||
|
||||
if (!id) {
|
||||
throw new Error(`Todo item ${index + 1}: id is required`);
|
||||
}
|
||||
if (seenIds.has(id)) {
|
||||
throw new Error(`Todo item ${id}: duplicate id`);
|
||||
}
|
||||
seenIds.add(id);
|
||||
|
||||
if (!text) {
|
||||
throw new Error(`Todo item ${id}: text is required`);
|
||||
}
|
||||
if (status !== 'pending' && status !== 'in_progress' && status !== 'completed') {
|
||||
throw new Error(`Todo item ${id}: invalid status '${status}'`);
|
||||
}
|
||||
|
||||
if (status === 'in_progress') {
|
||||
inProgressCount += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
text,
|
||||
status,
|
||||
...(activeText ? { activeText } : {}),
|
||||
updatedAt: now,
|
||||
};
|
||||
}).map((item) => {
|
||||
if (inProgressCount > 1) {
|
||||
throw new Error('Only one todo item can be in_progress at a time');
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
export function renderTodoList(items: TodoItem[]): string {
|
||||
if (items.length === 0) {
|
||||
return 'No todos.';
|
||||
}
|
||||
|
||||
const lines = items.map((item) => {
|
||||
const label = item.status === 'in_progress' && item.activeText ? item.activeText : item.text;
|
||||
return `${TODO_MARKERS[item.status]} #${item.id}: ${label}`;
|
||||
});
|
||||
const completed = items.filter(item => item.status === 'completed').length;
|
||||
lines.push(`\n(${completed}/${items.length} completed)`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function summarizeTodoCounts(items: TodoItem[]): {
|
||||
total: number;
|
||||
completed: number;
|
||||
inProgress: number;
|
||||
pending: number;
|
||||
} {
|
||||
return {
|
||||
total: items.length,
|
||||
completed: items.filter(item => item.status === 'completed').length,
|
||||
inProgress: items.filter(item => item.status === 'in_progress').length,
|
||||
pending: items.filter(item => item.status === 'pending').length,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTodoContext(items: TodoItem[]): string {
|
||||
if (items.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `Current todo state:\n${renderTodoList(items)}`;
|
||||
}
|
||||
@@ -2,9 +2,18 @@ import OpenAI from 'openai';
|
||||
import type { StreamChunk } from './StreamingTypes';
|
||||
import type { Message, ToolCall } from '../core/AgentState';
|
||||
import type { OpenAIToolDefinition } from '../tools/BaseTool';
|
||||
import { getModelTokenLimits } from './modelTokenLimits';
|
||||
|
||||
export interface LLMProvider {
|
||||
getPreferredSystemPromptPath?(): string | undefined;
|
||||
getContextWindow?(): number | undefined;
|
||||
getReservedOutputTokens?(): number | undefined;
|
||||
estimateHistoryTokens?(
|
||||
messages: Message[],
|
||||
systemPrompt?: string,
|
||||
tools?: OpenAIToolDefinition[],
|
||||
): Promise<number> | number;
|
||||
isContextTooLongError?(error: unknown): boolean;
|
||||
generateStream(
|
||||
messages: Message[],
|
||||
systemPrompt?: string,
|
||||
@@ -12,6 +21,28 @@ export interface LLMProvider {
|
||||
): AsyncIterableIterator<StreamChunk>;
|
||||
}
|
||||
|
||||
function extractErrorDetails(error: unknown): { code?: string; message?: string } {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const asRecord = error as Record<string, unknown>;
|
||||
const nestedError = asRecord.error && typeof asRecord.error === 'object'
|
||||
? asRecord.error as Record<string, unknown>
|
||||
: undefined;
|
||||
const code = typeof asRecord.code === 'string'
|
||||
? asRecord.code
|
||||
: typeof nestedError?.code === 'string'
|
||||
? nestedError.code
|
||||
: undefined;
|
||||
const message = typeof asRecord.message === 'string'
|
||||
? asRecord.message
|
||||
: typeof nestedError?.message === 'string'
|
||||
? nestedError.message
|
||||
: undefined;
|
||||
return { code, message };
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI-compatible provider implementation.
|
||||
* Works with OpenAI and OpenAI-compatible APIs (OpenRouter, Ollama, vLLM, etc.)
|
||||
@@ -31,6 +62,53 @@ export class OpenAICompatibleLLMProvider implements LLMProvider {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
getContextWindow(): number | undefined {
|
||||
return getModelTokenLimits(this.model)?.contextWindow;
|
||||
}
|
||||
|
||||
getReservedOutputTokens(): number | undefined {
|
||||
const limits = getModelTokenLimits(this.model);
|
||||
if (!limits) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof limits.reservedOutputTokens === 'number') {
|
||||
return limits.reservedOutputTokens;
|
||||
}
|
||||
|
||||
if (typeof limits.maxOutputTokens === 'number') {
|
||||
return Math.min(limits.maxOutputTokens, 8_192);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
estimateHistoryTokens(
|
||||
messages: Message[],
|
||||
systemPrompt?: string,
|
||||
tools?: OpenAIToolDefinition[],
|
||||
): number {
|
||||
const openaiMessages = this.convertMessages(messages, systemPrompt);
|
||||
const payload = JSON.stringify({
|
||||
model: this.model,
|
||||
messages: openaiMessages,
|
||||
tools: tools ?? [],
|
||||
});
|
||||
|
||||
// Conservative browser-side estimate for preflight checks.
|
||||
return Math.ceil(payload.length / 3);
|
||||
}
|
||||
|
||||
isContextTooLongError(error: unknown): boolean {
|
||||
const { code, message } = extractErrorDetails(error);
|
||||
if (code === 'context_length_exceeded') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return typeof message === 'string'
|
||||
&& /context window|maximum context length|input exceeds the context window|context too long/i.test(message);
|
||||
}
|
||||
|
||||
private convertMessages(
|
||||
messages: Message[],
|
||||
systemPrompt?: string,
|
||||
@@ -91,7 +169,8 @@ export class OpenAICompatibleLLMProvider implements LLMProvider {
|
||||
const toolCallAccumulator = new Map<number, { id: string; name: string; arguments: string }>();
|
||||
|
||||
for await (const chunk of stream) {
|
||||
console.log('LLMProvider: chunk', JSON.stringify(chunk));
|
||||
// Do not delete: leave this commented out for future debugging purpose.
|
||||
// console.log('LLMProvider: chunk', JSON.stringify(chunk));
|
||||
const choice = chunk.choices[0];
|
||||
if (!choice) continue;
|
||||
|
||||
|
||||
@@ -56,8 +56,31 @@ async function importMediaPipe(): Promise<MediaPipeGenAI> {
|
||||
export class LocalBrowserLLMProvider implements LLMProvider {
|
||||
private inference: GemmaInference | null = null;
|
||||
|
||||
getPreferredSystemPromptPath(): string | undefined {
|
||||
return 'prompts/system_compact.md';
|
||||
getContextWindow(): number {
|
||||
return this.getConfiguredContextLength();
|
||||
}
|
||||
|
||||
getReservedOutputTokens(): number {
|
||||
const contextWindow = this.getConfiguredContextLength();
|
||||
return Math.max(1024, Math.min(4096, Math.floor(contextWindow * 0.1)));
|
||||
}
|
||||
|
||||
async estimateHistoryTokens(
|
||||
messages: Message[],
|
||||
systemPrompt?: string,
|
||||
tools?: OpenAIToolDefinition[],
|
||||
): Promise<number> {
|
||||
const inference = await this.ensureInference();
|
||||
const prompt = this.renderPrompt(messages, systemPrompt, tools);
|
||||
return inference.sizeInTokens(prompt);
|
||||
}
|
||||
|
||||
isContextTooLongError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /context|token|maxTokens|kv-cache|too long|overflow/i.test(error.message);
|
||||
}
|
||||
|
||||
private async ensureInference(): Promise<GemmaInference> {
|
||||
|
||||
@@ -9,11 +9,19 @@ export interface PerformanceInfo {
|
||||
generationTps?: number;
|
||||
}
|
||||
|
||||
export type ToolApprovalDecision = 'allow' | 'always_allow' | 'deny';
|
||||
|
||||
export interface StreamChunk {
|
||||
type: 'text' | 'tool_call' | 'tool_result' | 'done';
|
||||
content: string;
|
||||
toolCall?: ToolCall;
|
||||
toolResult?: { name: string; success: boolean; result: string };
|
||||
toolResult?: {
|
||||
toolCallId?: string;
|
||||
name: string;
|
||||
success: boolean;
|
||||
result: string;
|
||||
denied?: boolean;
|
||||
};
|
||||
performanceInfo?: PerformanceInfo;
|
||||
finishReason?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getModelTokenLimits } from './modelTokenLimits';
|
||||
|
||||
describe('modelTokenLimits', () => {
|
||||
it('returns OpenAI limits for supported GPT models', () => {
|
||||
expect(getModelTokenLimits('gpt-5.2')).toEqual({
|
||||
contextWindow: 400_000,
|
||||
maxOutputTokens: 128_000,
|
||||
});
|
||||
|
||||
expect(getModelTokenLimits('gpt-4o')).toEqual({
|
||||
contextWindow: 128_000,
|
||||
maxOutputTokens: 16_384,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns Claude limits for supported direct and OpenRouter aliases', () => {
|
||||
expect(getModelTokenLimits('claude-sonnet-4.6')).toEqual({
|
||||
contextWindow: 200_000,
|
||||
reservedOutputTokens: 8_192,
|
||||
});
|
||||
|
||||
expect(getModelTokenLimits('anthropic/claude-opus-4.6')).toEqual({
|
||||
contextWindow: 200_000,
|
||||
reservedOutputTokens: 8_192,
|
||||
});
|
||||
});
|
||||
|
||||
it('matches snapshot-style suffixes by prefix', () => {
|
||||
expect(getModelTokenLimits('gpt-5.2-2025-12-11')).toEqual({
|
||||
contextWindow: 400_000,
|
||||
maxOutputTokens: 128_000,
|
||||
});
|
||||
|
||||
expect(getModelTokenLimits('anthropic/claude-sonnet-4.6-20260101')).toEqual({
|
||||
contextWindow: 200_000,
|
||||
reservedOutputTokens: 8_192,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns Gemini limits for supported Gemini models', () => {
|
||||
expect(getModelTokenLimits('gemini-2.5-flash')).toEqual({
|
||||
contextWindow: 1_048_576,
|
||||
maxOutputTokens: 65_536,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined for unknown models', () => {
|
||||
expect(getModelTokenLimits('custom-company-model')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
export interface ModelTokenLimits {
|
||||
contextWindow: number;
|
||||
maxOutputTokens?: number;
|
||||
reservedOutputTokens?: number;
|
||||
}
|
||||
|
||||
const MODEL_TOKEN_LIMITS: Record<string, ModelTokenLimits> = {
|
||||
// OpenAI official model pages / compare docs
|
||||
'gpt-5.2': { contextWindow: 400_000, maxOutputTokens: 128_000 },
|
||||
'gpt-5-mini': { contextWindow: 400_000, maxOutputTokens: 128_000 },
|
||||
'gpt-5-nano': { contextWindow: 400_000, maxOutputTokens: 128_000 },
|
||||
'gpt-5': { contextWindow: 400_000, maxOutputTokens: 128_000 },
|
||||
'gpt-4o': { contextWindow: 128_000, maxOutputTokens: 16_384 },
|
||||
|
||||
// GPT-5.4 family is present in current OpenAI docs, but exact limit pages were not surfaced.
|
||||
// Use GPT-5 family limits as a best-effort alias until exact per-model docs are available.
|
||||
'gpt-5.4': { contextWindow: 400_000, maxOutputTokens: 128_000 },
|
||||
'gpt-5.4-mini': { contextWindow: 400_000, maxOutputTokens: 128_000 },
|
||||
'gpt-5.4-nano': { contextWindow: 400_000, maxOutputTokens: 128_000 },
|
||||
|
||||
// Claude official docs: 200k standard context window across these families.
|
||||
// Anthropic docs do not expose a simple per-model max output token table for these aliases,
|
||||
// so we keep a conservative preflight reserve instead of claiming an exact output maximum.
|
||||
'claude-sonnet-4.6': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||
'claude-opus-4.6': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||
'claude-sonnet-4.5': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||
'claude-opus-4.5': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||
'claude-sonnet-4': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||
'claude-opus-4.1': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||
'anthropic/claude-sonnet-4.6': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||
'anthropic/claude-opus-4.6': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||
'anthropic/claude-sonnet-4.5': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||
'anthropic/claude-opus-4.5': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||
'anthropic/claude-sonnet-4': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||
'anthropic/claude-opus-4.1': { contextWindow: 200_000, reservedOutputTokens: 8_192 },
|
||||
|
||||
// Gemini official model docs
|
||||
'gemini-2.5-flash': { contextWindow: 1_048_576, maxOutputTokens: 65_536 },
|
||||
};
|
||||
|
||||
export function getModelTokenLimits(model: string): ModelTokenLimits | undefined {
|
||||
const exact = MODEL_TOKEN_LIMITS[model];
|
||||
if (exact) {
|
||||
return exact;
|
||||
}
|
||||
|
||||
const prefix = Object.keys(MODEL_TOKEN_LIMITS).find(key => model.startsWith(`${key}-`));
|
||||
return prefix ? MODEL_TOKEN_LIMITS[prefix] : undefined;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AddNotesTool } from './AddNotesTool';
|
||||
import {
|
||||
NO_MIDI_TARGET_HISTORY_MESSAGE,
|
||||
NO_MIDI_TARGET_RAW_MESSAGE,
|
||||
NO_MIDI_TARGET_UI_MESSAGE,
|
||||
} from './toolTargeting';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
||||
|
||||
const storeState = {
|
||||
activeRegionId: null as string | null,
|
||||
};
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: {
|
||||
getState: () => storeState,
|
||||
},
|
||||
}));
|
||||
|
||||
function mockCore(project: KGProject, selectedItems: unknown[] = []) {
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => selectedItems,
|
||||
executeCommand: (command: { execute(): void }) => command.execute(),
|
||||
} as unknown as KGCore);
|
||||
}
|
||||
|
||||
describe('AddNotesTool', () => {
|
||||
beforeEach(() => {
|
||||
storeState.activeRegionId = null;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('builds summaries for an active MIDI region target', () => {
|
||||
const track = new KGMidiTrack('Lead', 1);
|
||||
const region = new KGMidiRegion('region-1', track.getId().toString(), track.getTrackIndex(), 'Verse Melody', 0, 32);
|
||||
track.setRegions([region]);
|
||||
const project = new KGProject('summary-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
project.setTracks([track]);
|
||||
storeState.activeRegionId = region.getId();
|
||||
mockCore(project);
|
||||
|
||||
const tool = new AddNotesTool();
|
||||
const args = {
|
||||
notes: [
|
||||
{ pitch: 'C4', start: 16, length: 4 },
|
||||
{ pitch: 'E4', start: 20, length: 8 },
|
||||
],
|
||||
};
|
||||
|
||||
expect(tool.buildToolResultDisplayContent(args, { success: true, result: 'raw result' })).toBe(
|
||||
'Successfully created 2 notes in region **Verse Melody** on track **Lead**, spanning bars 5 to 7.',
|
||||
);
|
||||
expect(tool.buildConfirmationContent(args)).toBe(
|
||||
'Allow creating 2 notes on track **Lead** in region **Verse Melody**, spanning bars 5 to 7?',
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a new MIDI region on the requested track when no region overlaps', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1);
|
||||
const existingRegion = new KGMidiRegion('region-1', track.getId().toString(), track.getTrackIndex(), 'Intro', 0, 4);
|
||||
track.setRegions([existingRegion]);
|
||||
const project = new KGProject('create-region-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new AddNotesTool();
|
||||
const result = await tool.execute({
|
||||
track_id: track.getId().toString(),
|
||||
notes: [
|
||||
{ pitch: 'C4', start: 16, length: 2 },
|
||||
{ pitch: 'E4', start: 18, length: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(track.getRegions()).toHaveLength(2);
|
||||
const createdRegion = track.getRegions()[1] as KGMidiRegion;
|
||||
expect(createdRegion.getName()).toBe('Lead Region');
|
||||
expect(createdRegion.getStartFromBeat()).toBe(16);
|
||||
expect(createdRegion.getLength()).toBe(4);
|
||||
expect(createdRegion.getNotes()).toHaveLength(2);
|
||||
expect(createdRegion.getNotes().map(note => note.getStartBeat())).toEqual([0, 2]);
|
||||
});
|
||||
|
||||
it('targets a track by track_name when track_id is omitted', async () => {
|
||||
const targetTrack = new KGMidiTrack('Lead', 1);
|
||||
const otherTrack = new KGMidiTrack('Bass', 2);
|
||||
targetTrack.setRegions([new KGMidiRegion('region-1', targetTrack.getId().toString(), targetTrack.getTrackIndex(), 'Intro', 0, 4)]);
|
||||
const project = new KGProject('track-name-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
project.setTracks([targetTrack, otherTrack]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new AddNotesTool();
|
||||
const result = await tool.execute({
|
||||
track_name: 'Lead',
|
||||
notes: [{ pitch: 'C4', start: 16, length: 2 }],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(targetTrack.getRegions()).toHaveLength(2);
|
||||
expect(otherTrack.getRegions()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses track_id when both track_id and track_name are provided', async () => {
|
||||
const leadTrack = new KGMidiTrack('Lead', 1);
|
||||
const bassTrack = new KGMidiTrack('Bass', 2);
|
||||
const project = new KGProject('track-id-precedence-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
project.setTracks([leadTrack, bassTrack]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new AddNotesTool();
|
||||
const result = await tool.execute({
|
||||
track_id: bassTrack.getId().toString(),
|
||||
track_name: 'Lead',
|
||||
notes: [{ pitch: 'C4', start: 8, length: 2 }],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(leadTrack.getRegions()).toHaveLength(0);
|
||||
expect(bassTrack.getRegions()).toHaveLength(1);
|
||||
expect((bassTrack.getRegions()[0] as KGMidiRegion).getName()).toBe('Bass Region');
|
||||
});
|
||||
|
||||
it('uses the first matching track when duplicate track names exist', async () => {
|
||||
const firstLead = new KGMidiTrack('Lead', 1);
|
||||
const secondLead = new KGMidiTrack('Lead', 2);
|
||||
const project = new KGProject('duplicate-track-name-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
project.setTracks([firstLead, secondLead]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new AddNotesTool();
|
||||
const result = await tool.execute({
|
||||
track_name: 'Lead',
|
||||
notes: [{ pitch: 'C4', start: 4, length: 2 }],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(firstLead.getRegions()).toHaveLength(1);
|
||||
expect(secondLead.getRegions()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('chooses the largest overlapping region and auto-expands it to fit the notes', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1);
|
||||
const regionA = new KGMidiRegion('region-a', track.getId().toString(), track.getTrackIndex(), 'Region A', 0, 4);
|
||||
const regionB = new KGMidiRegion('region-b', track.getId().toString(), track.getTrackIndex(), 'Region B', 4, 4);
|
||||
regionB.setNotes([new KGMidiNote('note-existing', 0, 1, 60, 100)]);
|
||||
track.setRegions([regionA, regionB]);
|
||||
const project = new KGProject('expand-region-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new AddNotesTool();
|
||||
const result = await tool.execute({
|
||||
track_id: track.getId().toString(),
|
||||
notes: [{ pitch: 'G4', start: 2, length: 5 }],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(regionA.getNotes()).toHaveLength(0);
|
||||
expect(regionB.getStartFromBeat()).toBe(2);
|
||||
expect(regionB.getLength()).toBe(6);
|
||||
expect(regionB.getNotes()).toHaveLength(2);
|
||||
expect(regionB.getNotes().find(note => note.getId() === 'note-existing')?.getStartBeat()).toBe(2);
|
||||
expect(regionB.getNotes().find(note => note.getId() !== 'note-existing')?.getStartBeat()).toBe(0);
|
||||
});
|
||||
|
||||
it('returns distinct raw, history, and UI guidance when no MIDI target is available', async () => {
|
||||
const project = new KGProject('no-target-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new AddNotesTool();
|
||||
const args = { notes: [{ pitch: 'C4', start: 0, length: 1 }] };
|
||||
const result = await tool.execute(args);
|
||||
|
||||
expect(result).toEqual({ success: false, result: NO_MIDI_TARGET_RAW_MESSAGE });
|
||||
expect(tool.buildToolHistoryContent(args, result)).toBe(NO_MIDI_TARGET_HISTORY_MESSAGE);
|
||||
expect(tool.buildToolResultDisplayContent(args, result)).toBe(NO_MIDI_TARGET_UI_MESSAGE);
|
||||
});
|
||||
});
|
||||
+349
-131
@@ -1,18 +1,142 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolResult, ToolParameter } from './BaseTool';
|
||||
import {
|
||||
NO_MIDI_TARGET_HISTORY_MESSAGE,
|
||||
NO_MIDI_TARGET_RAW_MESSAGE,
|
||||
NO_MIDI_TARGET_UI_MESSAGE,
|
||||
getTrackDisplayName,
|
||||
resolveMidiTrackByIdOrName,
|
||||
resolveActiveOrSelectedMidiRegionContext,
|
||||
} from './toolTargeting';
|
||||
import { CreateNotesCommand } from '../../core/commands/note/CreateNotesCommand';
|
||||
import type { NoteCreationData } from '../../core/commands/note/CreateNotesCommand';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { KGCommand } from '../../core/commands/KGCommand';
|
||||
import { CreateRegionCommand } from '../../core/commands/region/CreateRegionCommand';
|
||||
import { ResizeRegionCommand } from '../../core/commands/region/ResizeRegionCommand';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
|
||||
interface RequestedNote {
|
||||
pitch: string;
|
||||
start: number;
|
||||
length: number;
|
||||
velocity?: number;
|
||||
}
|
||||
|
||||
interface AddNotesSummaryData {
|
||||
noteCount: number;
|
||||
regionName: string;
|
||||
trackName: string;
|
||||
earliestNoteStartBar: number;
|
||||
latestNoteEndBar: number;
|
||||
createdRegion: boolean;
|
||||
}
|
||||
|
||||
interface NoteSpan {
|
||||
startBeat: number;
|
||||
endBeat: number;
|
||||
}
|
||||
|
||||
interface ResolvedRegionContext {
|
||||
track: KGMidiTrack;
|
||||
trackName: string;
|
||||
regionName: string;
|
||||
regionId?: string;
|
||||
finalRegionStartBeat: number;
|
||||
finalRegionLength: number;
|
||||
createdRegion: boolean;
|
||||
}
|
||||
|
||||
class AddNotesToResolvedRegionCommand extends KGCommand {
|
||||
private readonly resolvedRegion: ResolvedRegionContext;
|
||||
private readonly notes: Array<RequestedNote & { midiPitch: number; velocity: number }>;
|
||||
private createRegionCommand: CreateRegionCommand | null = null;
|
||||
private resizeRegionCommand: ResizeRegionCommand | null = null;
|
||||
private createNotesCommand: CreateNotesCommand | null = null;
|
||||
|
||||
constructor(
|
||||
resolvedRegion: ResolvedRegionContext,
|
||||
notes: Array<RequestedNote & { midiPitch: number; velocity: number }>,
|
||||
) {
|
||||
super();
|
||||
this.resolvedRegion = resolvedRegion;
|
||||
this.notes = notes;
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
let regionId = this.resolvedRegion.regionId;
|
||||
|
||||
if (this.resolvedRegion.createdRegion) {
|
||||
this.createRegionCommand = new CreateRegionCommand(
|
||||
this.resolvedRegion.track.getId().toString(),
|
||||
this.resolvedRegion.track.getTrackIndex(),
|
||||
this.resolvedRegion.finalRegionStartBeat,
|
||||
this.resolvedRegion.finalRegionLength,
|
||||
this.resolvedRegion.regionName,
|
||||
);
|
||||
this.createRegionCommand.execute();
|
||||
regionId = this.createRegionCommand.getRegionId();
|
||||
} else if (regionId) {
|
||||
const existingRegion = this.findMidiRegion(regionId);
|
||||
if (
|
||||
existingRegion.getStartFromBeat() !== this.resolvedRegion.finalRegionStartBeat
|
||||
|| existingRegion.getLength() !== this.resolvedRegion.finalRegionLength
|
||||
) {
|
||||
this.resizeRegionCommand = new ResizeRegionCommand(
|
||||
regionId,
|
||||
this.resolvedRegion.finalRegionStartBeat,
|
||||
this.resolvedRegion.finalRegionLength,
|
||||
);
|
||||
this.resizeRegionCommand.execute();
|
||||
}
|
||||
}
|
||||
|
||||
if (!regionId) {
|
||||
throw new Error('Unable to resolve the MIDI region for note creation.');
|
||||
}
|
||||
|
||||
const noteCreationData: NoteCreationData[] = this.notes.map(note => ({
|
||||
regionId,
|
||||
startBeat: note.start - this.resolvedRegion.finalRegionStartBeat,
|
||||
endBeat: note.start - this.resolvedRegion.finalRegionStartBeat + note.length,
|
||||
pitch: note.midiPitch,
|
||||
velocity: note.velocity,
|
||||
}));
|
||||
this.createNotesCommand = new CreateNotesCommand(noteCreationData);
|
||||
this.createNotesCommand.execute();
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
this.createNotesCommand?.undo();
|
||||
this.resizeRegionCommand?.undo();
|
||||
this.createRegionCommand?.undo();
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Add ${this.notes.length} note${this.notes.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
private findMidiRegion(regionId: string): KGMidiRegion {
|
||||
const tracks = KGCore.instance().getCurrentProject().getTracks();
|
||||
for (const track of tracks) {
|
||||
const region = track.getRegions().find(candidate => candidate.getId() === regionId);
|
||||
if (region instanceof KGMidiRegion) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`MIDI region with ID "${regionId}" not found.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for adding notes to MIDI regions
|
||||
* Integrates with the existing command system for undo/redo support
|
||||
*/
|
||||
export class AddNotesTool extends BaseTool {
|
||||
readonly name = 'add_notes';
|
||||
readonly description = 'Add one or more MIDI notes to the current region. Use this to create melodies, chords, or any musical content. Notes use absolute beat positions on the project timeline — not relative to the region start.';
|
||||
readonly description = 'Add one or more MIDI notes to a target track or the currently active MIDI region. Use track_id when the user identifies a track. Regions are resolved or created automatically, so you should think in terms of tracks rather than clips. Notes use absolute beat positions on the project timeline.';
|
||||
|
||||
override isReadOnlyTool(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
notes: {
|
||||
@@ -26,182 +150,275 @@ export class AddNotesTool extends BaseTool {
|
||||
pitch: {
|
||||
type: 'string',
|
||||
description: 'Pitch in scientific notation: note name, optional accidental (# or b), and octave number. Examples: "C4" (middle C), "F#3" (F-sharp 3rd octave), "Bb2" (B-flat 2nd octave).',
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
start: {
|
||||
type: 'number',
|
||||
description: 'Start beat — the absolute beat position on the project timeline where the note begins. This is NOT relative to the region — beat 6 means beat 6 in the project regardless of where the region begins. Fractional values are supported (e.g., 0.5 = half a beat after beat 0).',
|
||||
required: true
|
||||
description: 'Start beat — the absolute beat position on the project timeline where the note begins. This is NOT relative to the region or clip — beat 6 means beat 6 in the project regardless of where any MIDI region begins. Fractional values are supported (e.g., 0.5 = half a beat after beat 0).',
|
||||
required: true,
|
||||
},
|
||||
length: {
|
||||
type: 'number',
|
||||
description: 'Duration of the note in beats. In 4/4 time: 4 = whole note, 2 = half note, 1 = quarter note, 0.5 = eighth note, 0.25 = sixteenth note.',
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
velocity: {
|
||||
type: 'number',
|
||||
description: 'Note velocity / loudness from 1 (softest) to 127 (loudest). Defaults to 127 if omitted.',
|
||||
required: false
|
||||
}
|
||||
}
|
||||
}
|
||||
required: false,
|
||||
},
|
||||
region_id: {
|
||||
},
|
||||
},
|
||||
},
|
||||
track_id: {
|
||||
type: 'string',
|
||||
description: 'Target region ID. If omitted, uses the currently active piano roll region or selected region.',
|
||||
required: false
|
||||
}
|
||||
description: 'Optional target MIDI track ID. If provided, the app automatically resolves the best overlapping MIDI region on that track for the requested note span, expands it if needed, or creates a new MIDI region when no overlap exists.',
|
||||
required: false,
|
||||
},
|
||||
track_name: {
|
||||
type: 'string',
|
||||
description: 'Optional target MIDI track name. Used only when track_id is omitted. If multiple MIDI tracks share the same name, the first matching track is used.',
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
|
||||
override buildToolResultDisplayContent(args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
if (!args) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!toolResult.success) {
|
||||
return toolResult.result === NO_MIDI_TARGET_RAW_MESSAGE ? NO_MIDI_TARGET_UI_MESSAGE : undefined;
|
||||
}
|
||||
|
||||
const summary = this.buildSummaryData(args);
|
||||
if (!summary) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const regionLabel = summary.createdRegion
|
||||
? `new region **${summary.regionName}**`
|
||||
: `region **${summary.regionName}**`;
|
||||
return `Successfully created ${summary.noteCount} ${summary.noteCount === 1 ? 'note' : 'notes'} in ${regionLabel} on track **${summary.trackName}**, spanning bars ${summary.earliestNoteStartBar} to ${summary.latestNoteEndBar}.`;
|
||||
}
|
||||
|
||||
override buildToolHistoryContent(args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
if (!args || toolResult.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return toolResult.result === NO_MIDI_TARGET_RAW_MESSAGE ? NO_MIDI_TARGET_HISTORY_MESSAGE : undefined;
|
||||
}
|
||||
|
||||
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
|
||||
if (!args) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const summary = this.buildSummaryData(args);
|
||||
if (!summary) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const regionVerb = summary.createdRegion ? 'a new region' : `region **${summary.regionName}**`;
|
||||
return `Allow creating ${summary.noteCount} ${summary.noteCount === 1 ? 'note' : 'notes'} on track **${summary.trackName}** in ${regionVerb}, spanning bars ${summary.earliestNoteStartBar} to ${summary.latestNoteEndBar}?`;
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
// Validate parameters
|
||||
this.validateParameters(params);
|
||||
|
||||
const notes = params.notes as Array<{
|
||||
pitch: string;
|
||||
start: number;
|
||||
length: number;
|
||||
velocity?: number;
|
||||
}>;
|
||||
|
||||
const regionId = params.region_id as string | undefined;
|
||||
|
||||
// Find the target region
|
||||
const targetRegion = this.findTargetRegion(regionId);
|
||||
if (!targetRegion) {
|
||||
return this.createErrorResult(
|
||||
regionId
|
||||
? `Region with ID "${regionId}" not found or is not a MIDI region`
|
||||
: 'No active or selected MIDI region found. Please open the piano roll with a region or select a MIDI region first.'
|
||||
);
|
||||
const notes = params.notes as RequestedNote[];
|
||||
if (notes.length === 0) {
|
||||
return this.createErrorResult('No notes were provided.');
|
||||
}
|
||||
|
||||
// Validate and convert notes to creation data
|
||||
const noteCreationData: NoteCreationData[] = [];
|
||||
const createdNotes: Array<{ pitch: string; start: number; length: number }> = [];
|
||||
|
||||
const validatedNotes: Array<RequestedNote & { midiPitch: number; velocity: number }> = [];
|
||||
for (const note of notes) {
|
||||
try {
|
||||
const midiPitch = this.convertPitchToMidi(note.pitch);
|
||||
const velocity = note.velocity ?? 127;
|
||||
|
||||
// Validate velocity range
|
||||
if (velocity < 1 || velocity > 127) {
|
||||
return this.createErrorResult(`Invalid velocity ${velocity}. Must be between 1 and 127.`);
|
||||
}
|
||||
|
||||
// Validate beat positions
|
||||
if (note.start < 0) {
|
||||
return this.createErrorResult(`Invalid start ${note.start}. Must be >= 0.`);
|
||||
}
|
||||
|
||||
if (note.length <= 0) {
|
||||
return this.createErrorResult(`Invalid length ${note.length}. Must be > 0.`);
|
||||
}
|
||||
|
||||
// Adjust note position relative to region's start beat
|
||||
const regionStartBeat = targetRegion.getStartFromBeat();
|
||||
const adjustedStartBeat = note.start - regionStartBeat;
|
||||
const adjustedEndBeat = adjustedStartBeat + note.length;
|
||||
|
||||
// Create note creation data
|
||||
noteCreationData.push({
|
||||
regionId: targetRegion.getId(),
|
||||
startBeat: adjustedStartBeat,
|
||||
endBeat: adjustedEndBeat,
|
||||
pitch: midiPitch,
|
||||
velocity
|
||||
});
|
||||
|
||||
createdNotes.push({
|
||||
pitch: note.pitch,
|
||||
start: note.start,
|
||||
length: note.length
|
||||
});
|
||||
|
||||
validatedNotes.push({ ...note, midiPitch, velocity });
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Invalid note pitch "${note.pitch}": ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the bulk note creation command
|
||||
const command = new CreateNotesCommand(noteCreationData);
|
||||
const trackId = params.track_id as string | undefined;
|
||||
const trackName = params.track_name as string | undefined;
|
||||
if (trackId || trackName) {
|
||||
const explicitTrack = resolveMidiTrackByIdOrName(trackId, trackName);
|
||||
if (!explicitTrack) {
|
||||
return this.createErrorResult(
|
||||
trackId
|
||||
? `Track with ID "${trackId}" not found or is not a MIDI track.`
|
||||
: `Track with name "${trackName}" not found or is not a MIDI track.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedRegion = this.resolveTargetRegion(trackId, trackName, this.getNoteSpan(validatedNotes));
|
||||
if (!resolvedRegion) {
|
||||
return this.createErrorResult(NO_MIDI_TARGET_RAW_MESSAGE);
|
||||
}
|
||||
|
||||
const command = new AddNotesToResolvedRegionCommand(resolvedRegion, validatedNotes);
|
||||
await this.executeCommand(command);
|
||||
|
||||
// Create success message
|
||||
const noteCount = createdNotes.length;
|
||||
const noteList = createdNotes
|
||||
const noteList = validatedNotes
|
||||
.map(note => `${note.pitch} (beat ${note.start}, length ${note.length})`)
|
||||
.join(', ');
|
||||
|
||||
return this.createSuccessResult(
|
||||
`Successfully created ${noteCount} note${noteCount > 1 ? 's' : ''}: ${noteList}`
|
||||
);
|
||||
const actionPrefix = resolvedRegion.createdRegion
|
||||
? `Successfully created ${validatedNotes.length} note${validatedNotes.length > 1 ? 's' : ''} on track "${resolvedRegion.trackName}" by creating MIDI region "${resolvedRegion.regionName}"`
|
||||
: `Successfully created ${validatedNotes.length} note${validatedNotes.length > 1 ? 's' : ''} in MIDI region "${resolvedRegion.regionName}" on track "${resolvedRegion.trackName}"`;
|
||||
|
||||
return this.createSuccessResult(`${actionPrefix}: ${noteList}`);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to create notes: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the target region for note creation
|
||||
* Priority: 1) Specified regionId, 2) Active piano roll region, 3) Selected regions, 4) Error if none found
|
||||
*/
|
||||
private findTargetRegion(regionId?: string): KGMidiRegion | null {
|
||||
const project = this.getCurrentProject();
|
||||
const tracks = project.getTracks();
|
||||
private buildSummaryData(args: Record<string, unknown>): AddNotesSummaryData | null {
|
||||
const typedArgs = args as {
|
||||
notes?: Array<{ start: number; length: number }>;
|
||||
track_id?: string;
|
||||
track_name?: string;
|
||||
};
|
||||
|
||||
if (regionId) {
|
||||
// Find specific region by ID
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === regionId);
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
// Smart region finding: try different sources in priority order
|
||||
|
||||
// 1. Try active piano roll region
|
||||
const storeState = useProjectStore.getState();
|
||||
if (storeState.activeRegionId) {
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === storeState.activeRegionId);
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try selected regions
|
||||
const core = this.getKGCore();
|
||||
const selectedItems = core.getSelectedItems();
|
||||
for (const item of selectedItems) {
|
||||
if (item instanceof KGMidiRegion) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. No fallback - return null to trigger error
|
||||
if (!Array.isArray(typedArgs.notes) || typedArgs.notes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const span = this.getNoteSpan(typedArgs.notes);
|
||||
const resolvedRegion = this.resolveTargetRegion(typedArgs.track_id, typedArgs.track_name, span);
|
||||
if (!resolvedRegion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KGCore instance for selection access
|
||||
*/
|
||||
private getKGCore() {
|
||||
return KGCore.instance();
|
||||
const beatsPerBar = this.getCurrentProject().getTimeSignature().numerator;
|
||||
|
||||
return {
|
||||
noteCount: typedArgs.notes.length,
|
||||
regionName: resolvedRegion.regionName,
|
||||
trackName: resolvedRegion.trackName,
|
||||
earliestNoteStartBar: Math.floor(span.startBeat / beatsPerBar) + 1,
|
||||
latestNoteEndBar: Math.max(1, Math.ceil(span.endBeat / beatsPerBar)),
|
||||
createdRegion: resolvedRegion.createdRegion,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveTargetRegion(
|
||||
trackId: string | undefined,
|
||||
trackName: string | undefined,
|
||||
span: NoteSpan,
|
||||
): ResolvedRegionContext | null {
|
||||
if (trackId || trackName) {
|
||||
return this.resolveTrackTarget(trackId, trackName, span);
|
||||
}
|
||||
|
||||
const activeRegion = resolveActiveOrSelectedMidiRegionContext();
|
||||
if (!activeRegion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const region = activeRegion.region;
|
||||
const regionStartBeat = region.getStartFromBeat();
|
||||
const regionEndBeat = regionStartBeat + region.getLength();
|
||||
return {
|
||||
track: activeRegion.track,
|
||||
trackName: activeRegion.trackName,
|
||||
regionId: region.getId(),
|
||||
regionName: region.getName(),
|
||||
finalRegionStartBeat: Math.min(regionStartBeat, span.startBeat),
|
||||
finalRegionLength: Math.max(regionEndBeat, span.endBeat) - Math.min(regionStartBeat, span.startBeat),
|
||||
createdRegion: false,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveTrackTarget(
|
||||
trackId: string | undefined,
|
||||
trackName: string | undefined,
|
||||
span: NoteSpan,
|
||||
): ResolvedRegionContext | null {
|
||||
const track = resolveMidiTrackByIdOrName(trackId, trackName);
|
||||
if (!track) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedTrackName = getTrackDisplayName(track);
|
||||
const midiRegions = track.getRegions().filter(region => region instanceof KGMidiRegion) as KGMidiRegion[];
|
||||
const selectedRegion = this.pickBestOverlappingRegion(midiRegions, span);
|
||||
|
||||
if (!selectedRegion) {
|
||||
return {
|
||||
track,
|
||||
trackName: resolvedTrackName,
|
||||
regionName: `${resolvedTrackName} Region`,
|
||||
finalRegionStartBeat: span.startBeat,
|
||||
finalRegionLength: span.endBeat - span.startBeat,
|
||||
createdRegion: true,
|
||||
};
|
||||
}
|
||||
|
||||
const regionStartBeat = selectedRegion.getStartFromBeat();
|
||||
const regionEndBeat = regionStartBeat + selectedRegion.getLength();
|
||||
const finalRegionStartBeat = Math.min(regionStartBeat, span.startBeat);
|
||||
const finalRegionEndBeat = Math.max(regionEndBeat, span.endBeat);
|
||||
|
||||
return {
|
||||
track,
|
||||
trackName: resolvedTrackName,
|
||||
regionId: selectedRegion.getId(),
|
||||
regionName: selectedRegion.getName(),
|
||||
finalRegionStartBeat,
|
||||
finalRegionLength: finalRegionEndBeat - finalRegionStartBeat,
|
||||
createdRegion: false,
|
||||
};
|
||||
}
|
||||
|
||||
private pickBestOverlappingRegion(regions: KGMidiRegion[], span: NoteSpan): KGMidiRegion | null {
|
||||
let bestRegion: KGMidiRegion | null = null;
|
||||
let bestOverlap = -1;
|
||||
let bestDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const region of regions) {
|
||||
const regionStart = region.getStartFromBeat();
|
||||
const regionEnd = regionStart + region.getLength();
|
||||
const overlap = Math.min(regionEnd, span.endBeat) - Math.max(regionStart, span.startBeat);
|
||||
if (overlap <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = Math.abs(regionStart - span.startBeat);
|
||||
if (overlap > bestOverlap || (overlap === bestOverlap && distance < bestDistance)) {
|
||||
bestRegion = region;
|
||||
bestOverlap = overlap;
|
||||
bestDistance = distance;
|
||||
}
|
||||
}
|
||||
|
||||
return bestRegion;
|
||||
}
|
||||
|
||||
private getNoteSpan(notes: Array<{ start: number; length: number }>): NoteSpan {
|
||||
return {
|
||||
startBeat: Math.min(...notes.map(note => note.start)),
|
||||
endBeat: Math.max(...notes.map(note => note.start + note.length)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert pitch string to MIDI note number
|
||||
* Supports formats like: C4, F#3, Bb2, C#5
|
||||
*/
|
||||
private convertPitchToMidi(pitch: string): number {
|
||||
const match = pitch.match(/^([A-G])([#b]?)(\d+)$/);
|
||||
if (!match) {
|
||||
@@ -209,23 +426,24 @@ export class AddNotesTool extends BaseTool {
|
||||
}
|
||||
|
||||
const [, noteName, accidental, octaveStr] = match;
|
||||
const octave = parseInt(octaveStr);
|
||||
|
||||
// Base MIDI notes for C octave (C4 = 60)
|
||||
const octave = parseInt(octaveStr, 10);
|
||||
const noteOffsets: Record<string, number> = {
|
||||
'C': 0, 'D': 2, 'E': 4, 'F': 5, 'G': 7, 'A': 9, 'B': 11
|
||||
C: 0,
|
||||
D: 2,
|
||||
E: 4,
|
||||
F: 5,
|
||||
G: 7,
|
||||
A: 9,
|
||||
B: 11,
|
||||
};
|
||||
|
||||
let midiNote = (octave + 1) * 12 + noteOffsets[noteName];
|
||||
|
||||
// Apply accidentals
|
||||
if (accidental === '#') {
|
||||
midiNote += 1;
|
||||
} else if (accidental === 'b') {
|
||||
midiNote -= 1;
|
||||
}
|
||||
|
||||
// Validate MIDI range
|
||||
if (midiNote < 0 || midiNote > 127) {
|
||||
throw new Error(`Note "${pitch}" is out of MIDI range (0-127)`);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ToolParameter {
|
||||
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
|
||||
description: string;
|
||||
required?: boolean;
|
||||
enum?: string[];
|
||||
items?: ToolParameter; // For array types
|
||||
properties?: Record<string, ToolParameter>; // For object types
|
||||
}
|
||||
@@ -66,6 +67,59 @@ export abstract class BaseTool {
|
||||
*/
|
||||
abstract execute(params: Record<string, unknown>): Promise<ToolResult>;
|
||||
|
||||
/**
|
||||
* Whether the tool only reads state and can execute without user approval.
|
||||
*/
|
||||
isReadOnlyTool(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the tool is available when the assistant runs in Regular Mode.
|
||||
*/
|
||||
isAvailableInRegularMode(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the tool is available when the assistant runs in Efficient Mode.
|
||||
*/
|
||||
isAvailableInEfficientMode(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionally build a compact UI summary for a successful tool result.
|
||||
* The raw tool result remains the canonical output stored in agent history.
|
||||
*/
|
||||
buildToolResultDisplayContent(
|
||||
_args: Record<string, unknown> | null,
|
||||
_toolResult: ToolResult,
|
||||
): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionally build the user-visible tool result text stored in chat history.
|
||||
* Raw tool results remain the canonical result returned to the LLM.
|
||||
*/
|
||||
buildToolHistoryContent(
|
||||
_args: Record<string, unknown> | null,
|
||||
_toolResult: ToolResult,
|
||||
): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionally build a user-facing confirmation summary before execution.
|
||||
* Non-read-only tools should override this with a concise approval prompt.
|
||||
*/
|
||||
buildConfirmationContent(
|
||||
_args: Record<string, unknown> | null,
|
||||
): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the tool definition in OpenAI function calling format
|
||||
*/
|
||||
@@ -114,6 +168,10 @@ export abstract class BaseTool {
|
||||
schema.items = this.convertParamToJsonSchema(param.items);
|
||||
}
|
||||
|
||||
if (param.enum) {
|
||||
schema.enum = param.enum;
|
||||
}
|
||||
|
||||
if (param.type === 'object' && param.properties) {
|
||||
const properties: Record<string, unknown> = {};
|
||||
const required: string[] = [];
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CreateNewTrackTool } from './CreateNewTrackTool';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: {
|
||||
getState: () => ({
|
||||
activeRegionId: null,
|
||||
selectedRegionIds: [],
|
||||
selectedTrackId: null,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
function mockCore(project: KGProject) {
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
executeCommand: (command: { execute(): void }) => command.execute(),
|
||||
} as unknown as KGCore);
|
||||
}
|
||||
|
||||
describe('CreateNewTrackTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(KGAudioInterface, 'instance').mockReturnValue({
|
||||
createTrackSynth: vi.fn(),
|
||||
} as unknown as KGAudioInterface);
|
||||
});
|
||||
|
||||
it('creates a new track and returns the exact output shape', async () => {
|
||||
const project = new KGProject('create-track-project');
|
||||
project.setTracks([new KGMidiTrack('Lead', 1, 'trumpet')]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new CreateNewTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_name: 'Bass',
|
||||
instrument: 'Electric Bass (finger)',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
result: 'New track created:\ntrack_id: 2\ntrack_name: Bass\ninstrument: Electric Bass (finger)',
|
||||
});
|
||||
expect(project.getTracks()).toHaveLength(2);
|
||||
expect(project.getTracks()[1]).toBeInstanceOf(KGMidiTrack);
|
||||
expect((project.getTracks()[1] as KGMidiTrack).getInstrument()).toBe('electric_bass_finger');
|
||||
expect(tool.isReadOnlyTool()).toBe(false);
|
||||
expect(tool.isAvailableInEfficientMode()).toBe(false);
|
||||
expect(tool.buildToolResultDisplayContent({
|
||||
track_name: 'Bass',
|
||||
instrument: 'Electric Bass (finger)',
|
||||
}, result)).toBe(
|
||||
'New track created:\n- track_id: 2\n- track_name: Bass\n- instrument: Electric Bass (finger)',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an invalid instrument name', async () => {
|
||||
const project = new KGProject('invalid-instrument-project');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new CreateNewTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_name: 'Bass',
|
||||
instrument: 'electric_bass_finger',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'Invalid instrument "electric_bass_finger". Use the exact English name from list_all_available_instruments.',
|
||||
});
|
||||
expect(project.getTracks()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { AddTrackCommand } from '../../core/commands/track/AddTrackCommand';
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import {
|
||||
getEnglishInstrumentName,
|
||||
resolveInstrumentKeyByEnglishName,
|
||||
} from './toolTargeting';
|
||||
|
||||
export class CreateNewTrackTool extends BaseTool {
|
||||
readonly name = 'create_new_track';
|
||||
readonly description =
|
||||
'Create a new MIDI track with a given track name and exact English instrument name. Use list_all_available_instruments first to discover valid instrument names.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
track_name: {
|
||||
type: 'string',
|
||||
description: 'Name of the new MIDI track to create.',
|
||||
required: true,
|
||||
},
|
||||
instrument: {
|
||||
type: 'string',
|
||||
description: 'Exact English instrument name from list_all_available_instruments.',
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
override isReadOnlyTool(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override isAvailableInEfficientMode(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
|
||||
if (!args) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trackName = typeof args.track_name === 'string' ? args.track_name : null;
|
||||
const instrument = typeof args.instrument === 'string' ? args.instrument : null;
|
||||
if (!trackName || !instrument) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `Allow creating track **${trackName}** with instrument **${instrument}**?`;
|
||||
}
|
||||
|
||||
override buildToolResultDisplayContent(args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
if (!args || !toolResult.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trackName = typeof args.track_name === 'string' ? args.track_name : null;
|
||||
const instrument = typeof args.instrument === 'string' ? args.instrument : null;
|
||||
if (!trackName || !instrument) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trackIdMatch = toolResult.result.match(/track_id:\s*(\d+)/);
|
||||
if (!trackIdMatch) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
'New track created:',
|
||||
`- track_id: ${trackIdMatch[1]}`,
|
||||
`- track_name: ${trackName}`,
|
||||
`- instrument: ${instrument}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
this.validateParameters(params);
|
||||
|
||||
const trackName = params.track_name as string;
|
||||
const instrumentName = params.instrument as string;
|
||||
const instrumentKey = resolveInstrumentKeyByEnglishName(instrumentName);
|
||||
if (!instrumentKey) {
|
||||
return this.createErrorResult(`Invalid instrument "${instrumentName}". Use the exact English name from list_all_available_instruments.`);
|
||||
}
|
||||
|
||||
const command = new AddTrackCommand(undefined, trackName, instrumentKey);
|
||||
await this.executeCommand(command);
|
||||
|
||||
return this.createSuccessResult([
|
||||
'New track created:',
|
||||
`track_id: ${command.getTrackId().toString()}`,
|
||||
`track_name: ${trackName}`,
|
||||
`instrument: ${getEnglishInstrumentName(instrumentKey)}`,
|
||||
].join('\n'));
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to create track: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DeleteTrackTool } from './DeleteTrackTool';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { KGAudioTrack } from '../../core/track/KGAudioTrack';
|
||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: {
|
||||
getState: () => ({
|
||||
activeRegionId: null,
|
||||
selectedRegionIds: [],
|
||||
selectedTrackId: null,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
function mockCore(project: KGProject) {
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
executeCommand: (command: { execute(): void }) => command.execute(),
|
||||
} as unknown as KGCore);
|
||||
}
|
||||
|
||||
describe('DeleteTrackTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(KGAudioInterface, 'instance').mockReturnValue({
|
||||
removeTrackSynth: vi.fn(),
|
||||
removeTrackAudioPlayerBus: vi.fn(),
|
||||
} as unknown as KGAudioInterface);
|
||||
});
|
||||
|
||||
it('deletes a MIDI track by track_id', async () => {
|
||||
const leadTrack = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const bassTrack = new KGMidiTrack('Bass', 2, 'acoustic_bass');
|
||||
const project = new KGProject('delete-by-id-project');
|
||||
project.setTracks([leadTrack, bassTrack]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new DeleteTrackTool();
|
||||
const result = await tool.execute({ track_id: '1' });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
result: 'Track deleted:\ntrack_id: 1\ntrack_name: Lead',
|
||||
});
|
||||
expect(project.getTracks().map(track => track.getName())).toEqual(['Bass']);
|
||||
expect(tool.buildToolResultDisplayContent({ track_id: '1' }, result)).toBe(
|
||||
'Track deleted:\n- track_id: 1\n- track_name: Lead',
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes a MIDI track by track_name', async () => {
|
||||
const leadTrack = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const bassTrack = new KGMidiTrack('Bass', 2, 'acoustic_bass');
|
||||
const project = new KGProject('delete-by-name-project');
|
||||
project.setTracks([leadTrack, bassTrack]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new DeleteTrackTool();
|
||||
const result = await tool.execute({ track_name: 'Bass' });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
result: 'Track deleted:\ntrack_id: 2\ntrack_name: Bass',
|
||||
});
|
||||
expect(project.getTracks().map(track => track.getName())).toEqual(['Lead']);
|
||||
});
|
||||
|
||||
it('uses track_id when both track_id and track_name are provided', async () => {
|
||||
const leadTrack = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const bassTrack = new KGMidiTrack('Bass', 2, 'acoustic_bass');
|
||||
const project = new KGProject('delete-track-id-precedence-project');
|
||||
project.setTracks([leadTrack, bassTrack]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new DeleteTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '2',
|
||||
track_name: 'Lead',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(project.getTracks().map(track => track.getName())).toEqual(['Lead']);
|
||||
});
|
||||
|
||||
it('rejects duplicate track names when track_id is omitted', async () => {
|
||||
const firstLead = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const secondLead = new KGMidiTrack('Lead', 2, 'flute');
|
||||
const project = new KGProject('delete-duplicate-name-project');
|
||||
project.setTracks([firstLead, secondLead]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new DeleteTrackTool();
|
||||
const result = await tool.execute({ track_name: 'Lead' });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'Multiple MIDI tracks share the name "Lead". Provide track_id instead.',
|
||||
});
|
||||
});
|
||||
|
||||
it('errors when neither identifier is provided', async () => {
|
||||
const project = new KGProject('delete-missing-id-project');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new DeleteTrackTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'Either track_id or track_name must be provided.',
|
||||
});
|
||||
});
|
||||
|
||||
it('errors when the target track does not exist', async () => {
|
||||
const project = new KGProject('delete-missing-track-project');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new DeleteTrackTool();
|
||||
const result = await tool.execute({ track_id: '99' });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'Track with ID "99" not found or is not a MIDI track.',
|
||||
});
|
||||
});
|
||||
|
||||
it('errors when the target is not a MIDI track', async () => {
|
||||
const audioTrack = new KGAudioTrack('Vocal', 1);
|
||||
const project = new KGProject('delete-audio-track-project');
|
||||
project.setTracks([audioTrack]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new DeleteTrackTool();
|
||||
const result = await tool.execute({ track_id: '1' });
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'Track with ID "1" not found or is not a MIDI track.',
|
||||
});
|
||||
expect(tool.isReadOnlyTool()).toBe(false);
|
||||
expect(tool.isAvailableInEfficientMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('generates confirmation content only for valid target input', () => {
|
||||
const tool = new DeleteTrackTool();
|
||||
|
||||
expect(tool.buildConfirmationContent({ track_id: '1' })).toBe('Allow deleting track ID **1**?');
|
||||
expect(tool.buildConfirmationContent({ track_name: 'Lead' })).toBe('Allow deleting track **Lead**?');
|
||||
expect(tool.buildConfirmationContent({})).toBeUndefined();
|
||||
expect(tool.buildConfirmationContent(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not generate display content for failed or malformed results', () => {
|
||||
const tool = new DeleteTrackTool();
|
||||
|
||||
expect(tool.buildToolResultDisplayContent(
|
||||
{ track_id: '1' },
|
||||
{ success: false, result: 'Track with ID "1" not found or is not a MIDI track.' },
|
||||
)).toBeUndefined();
|
||||
|
||||
expect(tool.buildToolResultDisplayContent(
|
||||
{ track_id: '1' },
|
||||
{ success: true, result: 'Track deleted.' },
|
||||
)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { RemoveTrackCommand } from '../../core/commands/track/RemoveTrackCommand';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import { resolveMidiTrackByExactName, resolveMidiTrackByIdOrName } from './toolTargeting';
|
||||
|
||||
export class DeleteTrackTool extends BaseTool {
|
||||
readonly name = 'delete_track';
|
||||
readonly description =
|
||||
'Delete an existing MIDI track by track_id or track_name. Prefer track_id because track_name may be duplicated.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
track_id: {
|
||||
type: 'string',
|
||||
description: 'Target MIDI track ID. Preferred when available.',
|
||||
required: false,
|
||||
},
|
||||
track_name: {
|
||||
type: 'string',
|
||||
description: 'Target MIDI track name. Used only when track_id is omitted.',
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
|
||||
override isReadOnlyTool(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override isAvailableInEfficientMode(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
|
||||
if (!args) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof args.track_id === 'string') {
|
||||
return `Allow deleting track ID **${args.track_id}**?`;
|
||||
}
|
||||
|
||||
if (typeof args.track_name === 'string') {
|
||||
return `Allow deleting track **${args.track_name}**?`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
override buildToolResultDisplayContent(args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
if (!args || !toolResult.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trackIdMatch = toolResult.result.match(/track_id:\s*(\d+)/);
|
||||
const trackNameMatch = toolResult.result.match(/track_name:\s*(.+)/);
|
||||
if (!trackIdMatch || !trackNameMatch) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
'Track deleted:',
|
||||
`- track_id: ${trackIdMatch[1]}`,
|
||||
`- track_name: ${trackNameMatch[1]}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
this.validateParameters(params);
|
||||
|
||||
const trackId = params.track_id as string | undefined;
|
||||
const trackName = params.track_name as string | undefined;
|
||||
|
||||
if (!trackId && !trackName) {
|
||||
return this.createErrorResult('Either track_id or track_name must be provided.');
|
||||
}
|
||||
|
||||
if (!trackId && trackName) {
|
||||
const matchingTracks = resolveMidiTrackByExactName(trackName);
|
||||
if (matchingTracks.length > 1) {
|
||||
return this.createErrorResult(`Multiple MIDI tracks share the name "${trackName}". Provide track_id instead.`);
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedTrack = resolveMidiTrackByIdOrName(trackId, trackName);
|
||||
if (!resolvedTrack) {
|
||||
return this.createErrorResult(
|
||||
trackId
|
||||
? `Track with ID "${trackId}" not found or is not a MIDI track.`
|
||||
: `Track with name "${trackName}" not found or is not a MIDI track.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!(resolvedTrack instanceof KGMidiTrack)) {
|
||||
return this.createErrorResult(
|
||||
trackId
|
||||
? `Track with ID "${trackId}" not found or is not a MIDI track.`
|
||||
: `Track with name "${trackName}" not found or is not a MIDI track.`,
|
||||
);
|
||||
}
|
||||
|
||||
const deletedTrackId = resolvedTrack.getId().toString();
|
||||
const deletedTrackName = resolvedTrack.getName();
|
||||
const command = new RemoveTrackCommand(resolvedTrack.getId());
|
||||
await this.executeCommand(command);
|
||||
|
||||
return this.createSuccessResult([
|
||||
'Track deleted:',
|
||||
`track_id: ${deletedTrackId}`,
|
||||
`track_name: ${deletedTrackName}`,
|
||||
].join('\n'));
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to delete track: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { GetUserSelectedMusicRangeAndTrackTool } from './GetUserSelectedMusicRangeAndTrackTool';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { KGGlobalTrack, GlobalTrackType } from '../../core/global-track/KGGlobalTrack';
|
||||
import { KGMarkerRegion } from '../../core/region/KGMarkerRegion';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
|
||||
const storeState = {
|
||||
activeRegionId: null as string | null,
|
||||
selectedRegionIds: [] as string[],
|
||||
selectedTrackId: null as string | null,
|
||||
};
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: {
|
||||
getState: () => storeState,
|
||||
},
|
||||
}));
|
||||
|
||||
function buildProject(): KGProject {
|
||||
const project = new KGProject('selection-tool-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
|
||||
const midiTrack = new KGMidiTrack('Lead', 1);
|
||||
midiTrack.setRegions([
|
||||
new KGMidiRegion('midi-a', midiTrack.getId().toString(), midiTrack.getTrackIndex(), 'A', 4, 8),
|
||||
new KGMidiRegion('midi-b', midiTrack.getId().toString(), midiTrack.getTrackIndex(), 'B', 20, 4),
|
||||
]);
|
||||
project.setTracks([midiTrack]);
|
||||
|
||||
const markerTrack = new KGGlobalTrack('global-marker', 0, GlobalTrackType.Marker, 'Marker');
|
||||
markerTrack.setRegions([
|
||||
new KGMarkerRegion('global-a', markerTrack.getId(), markerTrack.getTrackIndex(), 'Marker A', 2, 2),
|
||||
]);
|
||||
|
||||
project.setGlobalTracks(project.getGlobalTracks().map(track => (
|
||||
track.getType() === GlobalTrackType.Marker ? markerTrack : track
|
||||
)));
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
function mockCore(project: KGProject) {
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
} as unknown as KGCore);
|
||||
}
|
||||
|
||||
describe('GetUserSelectedMusicRangeAndTrackTool', () => {
|
||||
beforeEach(() => {
|
||||
storeState.activeRegionId = null;
|
||||
storeState.selectedRegionIds = [];
|
||||
storeState.selectedTrackId = null;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns the selected music range and selected regular track', async () => {
|
||||
const project = buildProject();
|
||||
mockCore(project);
|
||||
storeState.selectedRegionIds = ['midi-a', 'midi-b'];
|
||||
storeState.selectedTrackId = '1';
|
||||
|
||||
const tool = new GetUserSelectedMusicRangeAndTrackTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toBe(
|
||||
'Current Selected Music Range:\n- Start Beat: 4\n- End Beat: 24\n\nCurrent Selected Track:\ntrack_id: 1\ntrack_name: Lead',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports no selected track when the selection is global-only even if selectedTrackId is stale', async () => {
|
||||
const project = buildProject();
|
||||
mockCore(project);
|
||||
storeState.selectedRegionIds = ['global-a'];
|
||||
storeState.selectedTrackId = '1';
|
||||
|
||||
const tool = new GetUserSelectedMusicRangeAndTrackTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toBe(
|
||||
'Current Selected Music Range:\n- Start Beat: 2\n- End Beat: 4\n\nCurrent Selected Track:\nNo selected track.',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses loop bounds and reports no selected track when nothing is selected', async () => {
|
||||
const project = buildProject();
|
||||
project.setIsLooping(true);
|
||||
project.setLoopingRange([2, 5]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new GetUserSelectedMusicRangeAndTrackTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toBe(
|
||||
'Current Selected Music Range:\n- Start Beat: 8\n- End Beat: 24\n\nCurrent Selected Track:\nNo selected track.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import {
|
||||
resolveSelectedMusicRangeContext,
|
||||
resolveSelectedTrackContext,
|
||||
} from './toolTargeting';
|
||||
|
||||
export class GetUserSelectedMusicRangeAndTrackTool extends BaseTool {
|
||||
readonly name = 'get_user_selected_music_range_and_track';
|
||||
readonly description =
|
||||
'Get the current selected music range and the current selected regular track, if one is selected. Use this when selection context matters. When you are editing notes on the currently selected track, you do not need to pass track_id or track_name to note-editing tools.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {};
|
||||
|
||||
async execute(_params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
const selectedMusicRange = resolveSelectedMusicRangeContext();
|
||||
const selectedTrack = resolveSelectedTrackContext();
|
||||
|
||||
const selectedTrackSection = selectedTrack.hasSelectedTrack
|
||||
? `track_id: ${selectedTrack.trackId}\ntrack_name: ${selectedTrack.trackName}`
|
||||
: 'No selected track.';
|
||||
|
||||
return this.createSuccessResult(
|
||||
`Current Selected Music Range:\n${selectedMusicRange.section}\n\nCurrent Selected Track:\n${selectedTrackSection}`,
|
||||
);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to read current selected music range and track: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ListAllAvailableInstrumentsTool } from './ListAllAvailableInstrumentsTool';
|
||||
import { listAvailableInstrumentsByGroup } from './toolTargeting';
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: {
|
||||
getState: () => ({
|
||||
activeRegionId: null,
|
||||
selectedRegionIds: [],
|
||||
selectedTrackId: null,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ListAllAvailableInstrumentsTool', () => {
|
||||
it('lists instruments grouped by English group name with blank lines between groups', async () => {
|
||||
const tool = new ListAllAvailableInstrumentsTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result.startsWith(
|
||||
'Group: Piano and Keyboards\n- Acoustic Grand Piano\n- Bright Acoustic Piano',
|
||||
)).toBe(true);
|
||||
expect(result.result).toContain('\n\nGroup: Guitar\n- Acoustic Guitar (nylon)');
|
||||
expect(result.result).toContain('\n\nGroup: Bass\n- Acoustic Bass');
|
||||
expect(result.result).toContain('\n\nGroup: Percussion Kit\n- Standard Drum Kit');
|
||||
expect(result.result).toContain('\n\nGroup: Synthesizer\n- Lead 1 (square)');
|
||||
});
|
||||
|
||||
it('is unavailable in efficient mode', () => {
|
||||
const tool = new ListAllAvailableInstrumentsTool();
|
||||
|
||||
expect(tool.isAvailableInEfficientMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns a simplified UI display message', async () => {
|
||||
const tool = new ListAllAvailableInstrumentsTool();
|
||||
const result = await tool.execute({});
|
||||
const totalInstruments = listAvailableInstrumentsByGroup()
|
||||
.reduce((count, group) => count + group.instruments.length, 0);
|
||||
|
||||
expect(tool.buildToolResultDisplayContent({}, result)).toBe(`Listed ${totalInstruments} available instruments.`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import { listAvailableInstrumentsByGroup } from './toolTargeting';
|
||||
|
||||
export class ListAllAvailableInstrumentsTool extends BaseTool {
|
||||
readonly name = 'list_all_available_instruments';
|
||||
readonly description =
|
||||
'List all available instruments grouped by English instrument family names. Use this before creating a new MIDI track or changing a track instrument, because write tools require the exact English instrument name from this list.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {};
|
||||
|
||||
override isAvailableInEfficientMode(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override buildToolResultDisplayContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
if (!toolResult.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const totalInstruments = listAvailableInstrumentsByGroup()
|
||||
.reduce((count, group) => count + group.instruments.length, 0);
|
||||
return `Listed ${totalInstruments} available instruments.`;
|
||||
}
|
||||
|
||||
async execute(_params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
const result = listAvailableInstrumentsByGroup()
|
||||
.map(({ groupName, instruments }) => [
|
||||
`Group: ${groupName}`,
|
||||
...instruments.map(instrument => `- ${instrument}`),
|
||||
].join('\n'))
|
||||
.join('\n\n');
|
||||
|
||||
return this.createSuccessResult(result);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to list available instruments: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ListAllTracksTool } from './ListAllTracksTool';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { KGAudioTrack } from '../../core/track/KGAudioTrack';
|
||||
|
||||
function mockCore(project: KGProject) {
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
} as unknown as KGCore);
|
||||
}
|
||||
|
||||
describe('ListAllTracksTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('lists all MIDI tracks with English instrument names', async () => {
|
||||
const lead = new KGMidiTrack('Lead', 1, 'acoustic_grand_piano');
|
||||
const bass = new KGMidiTrack('Bass', 2, 'electric_bass_finger');
|
||||
const audio = new KGAudioTrack('Vocal', 3);
|
||||
lead.setTrackIndex(0);
|
||||
bass.setTrackIndex(1);
|
||||
audio.setTrackIndex(2);
|
||||
|
||||
const project = new KGProject('track-list-project');
|
||||
project.setTracks([lead, bass, audio]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new ListAllTracksTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toBe(
|
||||
'track_id: 1\ntrack_name: Lead\ninstrument: Acoustic Grand Piano\n\ntrack_id: 2\ntrack_name: Bass\ninstrument: Electric Bass (finger)',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a friendly message when there are no MIDI tracks', async () => {
|
||||
const project = new KGProject('no-midi-tracks');
|
||||
project.setTracks([new KGAudioTrack('Mixdown', 1)]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new ListAllTracksTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
result: 'No MIDI tracks found.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { FLUIDR3_INSTRUMENT_MAP } from '../../constants/generalMidiConstants';
|
||||
|
||||
export class ListAllTracksTool extends BaseTool {
|
||||
readonly name = 'list_all_tracks';
|
||||
readonly description =
|
||||
'List all MIDI tracks in the project with their track_id, track_name, and instrument name in English. Use this when you need to inspect available target tracks before choosing one.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {};
|
||||
|
||||
async execute(_params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
const midiTracks = this.getCurrentProject().getTracks().filter(
|
||||
(track): track is KGMidiTrack => track instanceof KGMidiTrack,
|
||||
);
|
||||
|
||||
if (midiTracks.length === 0) {
|
||||
return this.createSuccessResult('No MIDI tracks found.');
|
||||
}
|
||||
|
||||
const result = midiTracks.map(track => {
|
||||
const instrumentKey = track.getInstrument();
|
||||
const instrumentName = FLUIDR3_INSTRUMENT_MAP[instrumentKey]?.displayName ?? instrumentKey;
|
||||
const trackName = track.getName() || `Track ${track.getTrackIndex() + 1}`;
|
||||
|
||||
return [
|
||||
`track_id: ${track.getId().toString()}`,
|
||||
`track_name: ${trackName}`,
|
||||
`instrument: ${instrumentName}`,
|
||||
].join('\n');
|
||||
}).join('\n\n');
|
||||
|
||||
return this.createSuccessResult(result);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to list tracks: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ReadBpmTool } from './ReadBpmTool';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { GlobalTrackType } from '../../core/global-track';
|
||||
import { KGTempoRegion } from '../../core/region/KGTempoRegion';
|
||||
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
|
||||
|
||||
describe('ReadBpmTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('is only available in regular mode', () => {
|
||||
const tool = new ReadBpmTool();
|
||||
expect(tool.isAvailableInEfficientMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('reads ordered BPM regions from the global tempo track', async () => {
|
||||
const project = new KGProject('read-bpm', 8, 0, 120, { numerator: 4, denominator: 4 });
|
||||
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||
expect(track).not.toBeNull();
|
||||
track!.setRegions([
|
||||
new KGTempoRegion('region-2', track!.getId(), track!.getTrackIndex(), 140, 4, 4, 4),
|
||||
new KGTempoRegion('region-1', track!.getId(), track!.getTrackIndex(), 120, 0, 4, 4),
|
||||
]);
|
||||
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [],
|
||||
} as unknown as KGCore);
|
||||
|
||||
const tool = new ReadBpmTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toBe('[Beat: 0]: 120 BPM\n[Beat: 16]: 140 BPM');
|
||||
});
|
||||
|
||||
it('preserves line breaks in UI and history display content', () => {
|
||||
const tool = new ReadBpmTool();
|
||||
const formatted = '[Beat: 0]: 120 BPM \n[Beat: 16]: 140 BPM';
|
||||
const raw = '[Beat: 0]: 120 BPM\n[Beat: 16]: 140 BPM';
|
||||
|
||||
expect(tool.buildToolResultDisplayContent(null, {
|
||||
success: true,
|
||||
result: raw,
|
||||
})).toBe(formatted);
|
||||
expect(tool.buildToolHistoryContent(null, {
|
||||
success: true,
|
||||
result: raw,
|
||||
})).toBe(raw);
|
||||
});
|
||||
|
||||
it('falls back to the project-level BPM at beat 0 when no regions exist', async () => {
|
||||
const project = new KGProject('fallback-bpm', 8, 0, 132, { numerator: 4, denominator: 4 });
|
||||
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [],
|
||||
} as unknown as KGCore);
|
||||
|
||||
const tool = new ReadBpmTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toBe('[Beat: 0]: 132 BPM');
|
||||
});
|
||||
|
||||
it('returns a clean failure when the tempo track is missing', async () => {
|
||||
const project = new KGProject('missing-tempo-track', 8, 0, 120, { numerator: 4, denominator: 4 });
|
||||
project.setGlobalTracks([]);
|
||||
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [],
|
||||
} as unknown as KGCore);
|
||||
|
||||
const tool = new ReadBpmTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('Tempo global track not found');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import { GlobalTrackType } from '../../core/global-track';
|
||||
import { KGTempoRegion } from '../../core/region/KGTempoRegion';
|
||||
import { findGlobalTrackByType, getSortedTempoRegions } from '../../util/globalTrackUtil';
|
||||
|
||||
export class ReadBpmTool extends BaseTool {
|
||||
readonly name = 'read_bpm';
|
||||
readonly description = 'Read the BPM changes from the global Tempo track. If no tempo regions exist, fall back to the project-level BPM and return it at beat 0.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {};
|
||||
|
||||
override isAvailableInEfficientMode(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override buildToolResultDisplayContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
return this.formatMultilineResult(toolResult.result);
|
||||
}
|
||||
|
||||
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
return toolResult.result;
|
||||
}
|
||||
|
||||
async execute(_params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
const project = this.getCurrentProject();
|
||||
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||
if (!track) {
|
||||
return this.createErrorResult('Tempo global track not found');
|
||||
}
|
||||
|
||||
const beatsPerBar = project.getTimeSignature().numerator;
|
||||
const regions = getSortedTempoRegions(track, beatsPerBar)
|
||||
.filter((region): region is KGTempoRegion => region instanceof KGTempoRegion);
|
||||
|
||||
if (regions.length === 0) {
|
||||
return this.createSuccessResult(`[Beat: 0]: ${project.getBpm()} BPM`);
|
||||
}
|
||||
|
||||
const result = regions
|
||||
.map(region => `[Beat: ${region.getStartFromBeat()}]: ${region.getBpm()} BPM`)
|
||||
.join('\n');
|
||||
return this.createSuccessResult(result);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to read BPM: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private formatMultilineResult(result: string): string {
|
||||
return result.replace(/\n/g, ' \n');
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ function buildProjectWithRegionAndOptionalChords(chords: string[] = []): {
|
||||
|
||||
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
|
||||
expect(chordTrack).not.toBeNull();
|
||||
|
||||
chords.forEach((symbol, index) => {
|
||||
chordTrack!.addRegion(new KGChordRegion(`chord-${index}`, chordTrack!.getId(), chordTrack!.getTrackIndex(), symbol, index * 4, 4));
|
||||
});
|
||||
@@ -59,27 +58,10 @@ describe('ReadChordProgressionTool', () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toContain('Chord-symbol representation:');
|
||||
expect(result.result).toContain('[Am]4 | [F]4 | [Dm]4 | [E7]4 | [Am]4 | [C]4 | [Dm]4 | [E7]4 |');
|
||||
expect(result.result).toContain('[A, C E]4 | [F, A, C]4 | [D F A]4 | [E ^G B d]4 | [A, C E]4 | [C E G]4 | [D F A]4 | [E ^G B d]4 |');
|
||||
});
|
||||
|
||||
it('falls back to the selected MIDI region when no active region exists', async () => {
|
||||
const { project, midiRegion } = buildProjectWithRegionAndOptionalChords(['Am']);
|
||||
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [midiRegion],
|
||||
} as unknown as KGCore);
|
||||
|
||||
const tool = new ReadChordProgressionTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toContain('[Am]4 |');
|
||||
});
|
||||
|
||||
it('returns guidance when no chord progression is defined for the region range', async () => {
|
||||
const { project, midiRegion } = buildProjectWithRegionAndOptionalChords();
|
||||
storeState.activeRegionId = midiRegion.getId();
|
||||
it('reads the full chord track when no MIDI region is selected', async () => {
|
||||
const { project } = buildProjectWithRegionAndOptionalChords(['Am', 'F', 'Dm']);
|
||||
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
@@ -90,12 +72,12 @@ describe('ReadChordProgressionTool', () => {
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toContain('No chord progression is defined for the selected MIDI region range.');
|
||||
expect(result.result).toContain('read_music');
|
||||
expect(result.result).toContain('[Am]4 | [F]4 | [Dm]4 |');
|
||||
expect(tool.buildToolResultDisplayContent({}, result)).toBe('Read the chord progression from bars 1 to 3.');
|
||||
});
|
||||
|
||||
it('returns a clear error when no active or selected MIDI region exists', async () => {
|
||||
const { project } = buildProjectWithRegionAndOptionalChords(['Am']);
|
||||
it('returns no-chord-defined guidance when the chord track has no chord regions', async () => {
|
||||
const { project } = buildProjectWithRegionAndOptionalChords();
|
||||
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
@@ -105,7 +87,11 @@ describe('ReadChordProgressionTool', () => {
|
||||
const tool = new ReadChordProgressionTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('No active or selected MIDI region found');
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toBe('No chord progression is defined for the requested range on the global chord track. Use read_music to inspect the notes directly.');
|
||||
expect(tool.buildToolHistoryContent({}, result)).toBe(
|
||||
'No chord progression is defined for that range on the global chord track. Use read_music to inspect the notes directly.',
|
||||
);
|
||||
expect(tool.buildToolResultDisplayContent({}, result)).toBe('No chord progression is defined for that range.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,60 +1,105 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolResult, ToolParameter } from './BaseTool';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { resolveActiveOrSelectedMidiRegionContext } from './toolTargeting';
|
||||
import { convertBeatRangeChordProgressionToABCNotation } from '../../util/abcNotationUtil';
|
||||
import { GlobalTrackType } from '../../core/global-track';
|
||||
import { KGChordRegion } from '../../core/region/KGChordRegion';
|
||||
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
|
||||
|
||||
interface ChordProgressionRange {
|
||||
startBeat: number;
|
||||
endBeat: number;
|
||||
scope: 'region' | 'song';
|
||||
}
|
||||
|
||||
const NO_CHORD_PROGRESSION_RAW_MESSAGE =
|
||||
'No chord progression is defined for the requested range on the global chord track. Use read_music to inspect the notes directly.';
|
||||
|
||||
const NO_CHORD_PROGRESSION_HISTORY_MESSAGE =
|
||||
'No chord progression is defined for that range on the global chord track. Use read_music to inspect the notes directly.';
|
||||
|
||||
const NO_CHORD_PROGRESSION_UI_MESSAGE =
|
||||
'No chord progression is defined for that range.';
|
||||
|
||||
/**
|
||||
* Tool for reading user-defined chord progression content from the global chord track.
|
||||
*/
|
||||
export class ReadChordProgressionTool extends BaseTool {
|
||||
readonly name = 'read_chord_progression';
|
||||
readonly description = 'Read the user-defined chord progression for the currently active or selected MIDI region. The output has two representations of the same progression: first symbolic chord names such as Em7b5, then note-based ABC chord tokens. Chord progression data comes only from chord regions the user defined on the global chord track, so it may be empty. If no chord progression is defined for this range, read the notes directly with read_music.';
|
||||
readonly description = 'Read the user-defined chord progression from the global chord track. If a MIDI region is active or selected, read the progression for that region. Otherwise, read the full song progression from bar 1 through the last chord region on the chord track.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {};
|
||||
|
||||
async execute(_params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
const targetRegion = this.findTargetRegion();
|
||||
if (!targetRegion) {
|
||||
return this.createErrorResult(
|
||||
'No active or selected MIDI region found. Please open the piano roll with a region or select a MIDI region first.'
|
||||
);
|
||||
override buildToolResultDisplayContent(args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
void args;
|
||||
|
||||
if (!toolResult.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const project = this.getCurrentProject();
|
||||
const startBeat = targetRegion.getStartFromBeat();
|
||||
const endBeat = startBeat + targetRegion.getLength();
|
||||
const result = convertBeatRangeChordProgressionToABCNotation(project, startBeat, endBeat);
|
||||
if (toolResult.result === NO_CHORD_PROGRESSION_RAW_MESSAGE) {
|
||||
return NO_CHORD_PROGRESSION_UI_MESSAGE;
|
||||
}
|
||||
|
||||
const range = this.resolveReadRange();
|
||||
if (!range) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const beatsPerBar = this.getCurrentProject().getTimeSignature().numerator;
|
||||
const startBar = Math.floor(range.startBeat / beatsPerBar) + 1;
|
||||
const endBar = Math.max(1, Math.ceil(range.endBeat / beatsPerBar));
|
||||
return `Read the chord progression from ${startBar === endBar ? `bar ${startBar}` : `bars ${startBar} to ${endBar}`}.`;
|
||||
}
|
||||
|
||||
override buildToolHistoryContent(args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
void args;
|
||||
if (toolResult.result === NO_CHORD_PROGRESSION_RAW_MESSAGE) {
|
||||
return NO_CHORD_PROGRESSION_HISTORY_MESSAGE;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async execute(_params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
const range = this.resolveReadRange();
|
||||
if (!range) {
|
||||
return this.createSuccessResult(NO_CHORD_PROGRESSION_RAW_MESSAGE);
|
||||
}
|
||||
|
||||
const result = convertBeatRangeChordProgressionToABCNotation(
|
||||
this.getCurrentProject(),
|
||||
range.startBeat,
|
||||
range.endBeat,
|
||||
);
|
||||
return this.createSuccessResult(result);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to read chord progression: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private findTargetRegion(): KGMidiRegion | null {
|
||||
const project = this.getCurrentProject();
|
||||
const tracks = project.getTracks();
|
||||
const storeState = useProjectStore.getState();
|
||||
|
||||
if (storeState.activeRegionId) {
|
||||
for (const track of tracks) {
|
||||
const region = track.getRegions().find(candidate => candidate.getId() === storeState.activeRegionId);
|
||||
if (region instanceof KGMidiRegion) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selectedItems = KGCore.instance().getSelectedItems();
|
||||
for (const item of selectedItems) {
|
||||
if (item instanceof KGMidiRegion) {
|
||||
return item;
|
||||
}
|
||||
private resolveReadRange(): ChordProgressionRange | null {
|
||||
const resolvedRegion = resolveActiveOrSelectedMidiRegionContext();
|
||||
if (resolvedRegion) {
|
||||
return {
|
||||
startBeat: resolvedRegion.region.getStartFromBeat(),
|
||||
endBeat: resolvedRegion.region.getStartFromBeat() + resolvedRegion.region.getLength(),
|
||||
scope: 'region',
|
||||
};
|
||||
}
|
||||
|
||||
const chordTrack = findGlobalTrackByType(this.getCurrentProject(), GlobalTrackType.Chord);
|
||||
if (!chordTrack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chordRegions = chordTrack.getRegions().filter((region): region is KGChordRegion => region instanceof KGChordRegion);
|
||||
if (chordRegions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
startBeat: 0,
|
||||
endBeat: Math.max(...chordRegions.map(region => region.getStartFromBeat() + region.getLength())),
|
||||
scope: 'song',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ReadKeySignatureTool } from './ReadKeySignatureTool';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { GlobalTrackType } from '../../core/global-track';
|
||||
import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
|
||||
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
|
||||
|
||||
describe('ReadKeySignatureTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('is only available in regular mode', () => {
|
||||
const tool = new ReadKeySignatureTool();
|
||||
expect(tool.isAvailableInEfficientMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('reads ordered key-signature regions from the global signature track', async () => {
|
||||
const project = new KGProject('read-signatures', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
|
||||
expect(track).not.toBeNull();
|
||||
track!.setRegions([
|
||||
new KGKeySignatureRegion('region-2', track!.getId(), track!.getTrackIndex(), 'D major', 4, 4, 4),
|
||||
new KGKeySignatureRegion('region-1', track!.getId(), track!.getTrackIndex(), 'G major', 0, 4, 4),
|
||||
]);
|
||||
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [],
|
||||
} as unknown as KGCore);
|
||||
|
||||
const tool = new ReadKeySignatureTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toBe('[Beat: 0]: G major\n[Beat: 16]: D major');
|
||||
});
|
||||
|
||||
it('preserves line breaks in UI and history display content', () => {
|
||||
const tool = new ReadKeySignatureTool();
|
||||
const formatted = '[Beat: 0]: G major \n[Beat: 16]: D major';
|
||||
const raw = '[Beat: 0]: G major\n[Beat: 16]: D major';
|
||||
|
||||
expect(tool.buildToolResultDisplayContent(null, {
|
||||
success: true,
|
||||
result: raw,
|
||||
})).toBe(formatted);
|
||||
expect(tool.buildToolHistoryContent(null, {
|
||||
success: true,
|
||||
result: raw,
|
||||
})).toBe(raw);
|
||||
});
|
||||
|
||||
it('falls back to the project-level key signature at beat 0 when no regions exist', async () => {
|
||||
const project = new KGProject('fallback-signatures', 8, 0, 120, { numerator: 4, denominator: 4 }, 'E minor');
|
||||
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [],
|
||||
} as unknown as KGCore);
|
||||
|
||||
const tool = new ReadKeySignatureTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toBe('[Beat: 0]: E minor');
|
||||
});
|
||||
|
||||
it('returns a clean failure when the signature track is missing', async () => {
|
||||
const project = new KGProject('missing-signature-track', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
project.setGlobalTracks([]);
|
||||
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [],
|
||||
} as unknown as KGCore);
|
||||
|
||||
const tool = new ReadKeySignatureTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('Signature global track not found');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import { GlobalTrackType } from '../../core/global-track';
|
||||
import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
|
||||
import { findGlobalTrackByType, getSortedKeySignatureRegions } from '../../util/globalTrackUtil';
|
||||
|
||||
export class ReadKeySignatureTool extends BaseTool {
|
||||
readonly name = 'read_key_signature';
|
||||
readonly description = 'Read the key-signature changes from the global Signature track. If no key-signature regions exist, fall back to the project-level key signature and return it at beat 0.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {};
|
||||
|
||||
override isAvailableInEfficientMode(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override buildToolResultDisplayContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
return this.formatMultilineResult(toolResult.result);
|
||||
}
|
||||
|
||||
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
return toolResult.result;
|
||||
}
|
||||
|
||||
async execute(_params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
const project = this.getCurrentProject();
|
||||
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
|
||||
if (!track) {
|
||||
return this.createErrorResult('Signature global track not found');
|
||||
}
|
||||
|
||||
const beatsPerBar = project.getTimeSignature().numerator;
|
||||
const regions = getSortedKeySignatureRegions(track, beatsPerBar)
|
||||
.filter((region): region is KGKeySignatureRegion => region instanceof KGKeySignatureRegion);
|
||||
|
||||
if (regions.length === 0) {
|
||||
return this.createSuccessResult(`[Beat: 0]: ${project.getKeySignature()}`);
|
||||
}
|
||||
|
||||
const result = regions
|
||||
.map(region => `[Beat: ${region.getStartFromBeat()}]: ${region.getKeySignature()}`)
|
||||
.join('\n');
|
||||
return this.createSuccessResult(result);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to read key signature: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private formatMultilineResult(result: string): string {
|
||||
return result.replace(/\n/g, ' \n');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ReadMarkersTool } from './ReadMarkersTool';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { GlobalTrackType } from '../../core/global-track';
|
||||
import { KGMarkerRegion } from '../../core/region/KGMarkerRegion';
|
||||
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
|
||||
|
||||
describe('ReadMarkersTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('is only available in regular mode', () => {
|
||||
const tool = new ReadMarkersTool();
|
||||
expect(tool.isAvailableInEfficientMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('reads ordered marker regions from the global marker track', async () => {
|
||||
const project = new KGProject('read-markers', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const track = findGlobalTrackByType(project, GlobalTrackType.Marker);
|
||||
expect(track).not.toBeNull();
|
||||
track!.setRegions([
|
||||
new KGMarkerRegion('region-2', track!.getId(), track!.getTrackIndex(), 'Verse', 8, 4),
|
||||
new KGMarkerRegion('region-1', track!.getId(), track!.getTrackIndex(), 'Intro', 0, 8),
|
||||
]);
|
||||
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [],
|
||||
} as unknown as KGCore);
|
||||
|
||||
const tool = new ReadMarkersTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toBe('[Beat: 0; Length: 8]: Intro\n[Beat: 8; Length: 4]: Verse');
|
||||
});
|
||||
|
||||
it('preserves line breaks in UI and history display content', () => {
|
||||
const tool = new ReadMarkersTool();
|
||||
const formatted = '[Beat: 0; Length: 8]: Intro \n[Beat: 8; Length: 4]: Verse';
|
||||
const raw = '[Beat: 0; Length: 8]: Intro\n[Beat: 8; Length: 4]: Verse';
|
||||
|
||||
expect(tool.buildToolResultDisplayContent(null, {
|
||||
success: true,
|
||||
result: raw,
|
||||
})).toBe(formatted);
|
||||
expect(tool.buildToolHistoryContent(null, {
|
||||
success: true,
|
||||
result: raw,
|
||||
})).toBe(raw);
|
||||
});
|
||||
|
||||
it('returns an absence message when no marker regions exist', async () => {
|
||||
const project = new KGProject('empty-markers', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [],
|
||||
} as unknown as KGCore);
|
||||
|
||||
const tool = new ReadMarkersTool();
|
||||
const result = await tool.execute({});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toBe('No marker regions found on the global Marker track.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import { GlobalTrackType } from '../../core/global-track';
|
||||
import { KGMarkerRegion } from '../../core/region/KGMarkerRegion';
|
||||
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
|
||||
|
||||
export class ReadMarkersTool extends BaseTool {
|
||||
readonly name = 'read_markers';
|
||||
readonly description = 'Read marker annotations from the global Marker track. Marker regions are timeline annotations only and do not affect playback.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {};
|
||||
|
||||
override isAvailableInEfficientMode(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override buildToolResultDisplayContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
return this.formatMultilineResult(toolResult.result);
|
||||
}
|
||||
|
||||
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
return toolResult.result;
|
||||
}
|
||||
|
||||
async execute(_params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
const project = this.getCurrentProject();
|
||||
const track = findGlobalTrackByType(project, GlobalTrackType.Marker);
|
||||
if (!track) {
|
||||
return this.createErrorResult('Marker global track not found');
|
||||
}
|
||||
|
||||
const regions = track.getRegions()
|
||||
.filter((region): region is KGMarkerRegion => region instanceof KGMarkerRegion)
|
||||
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
|
||||
|
||||
if (regions.length === 0) {
|
||||
return this.createSuccessResult('No marker regions found on the global Marker track.');
|
||||
}
|
||||
|
||||
const result = regions
|
||||
.map(region => `[Beat: ${region.getStartFromBeat()}; Length: ${region.getLength()}]: ${region.getName()}`)
|
||||
.join('\n');
|
||||
return this.createSuccessResult(result);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to read markers: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private formatMultilineResult(result: string): string {
|
||||
return result.replace(/\n/g, ' \n');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ReadMusicTool } from './ReadMusicTool';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
||||
|
||||
function buildTrack(name: string, id: number, regionStartBeat: number, regionLength: number): KGMidiTrack {
|
||||
const track = new KGMidiTrack(name, id);
|
||||
const region = new KGMidiRegion(`region-${id}`, track.getId().toString(), track.getTrackIndex(), `${name} Region`, regionStartBeat, regionLength);
|
||||
region.addNote(new KGMidiNote(`note-${id}`, 0, 4, 60));
|
||||
track.setRegions([region]);
|
||||
return track;
|
||||
}
|
||||
|
||||
describe('ReadMusicTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('builds a compact summary for reading multiple tracks including empty ones', () => {
|
||||
const project = new KGProject('read-music-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const leadTrack = buildTrack('Lead', 1, 0, 16);
|
||||
const emptyTrack = new KGMidiTrack('Pads', 2);
|
||||
project.setTracks([leadTrack, emptyTrack]);
|
||||
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
} as unknown as KGCore);
|
||||
|
||||
const tool = new ReadMusicTool();
|
||||
const summary = tool.buildToolResultDisplayContent(
|
||||
{
|
||||
track_id: 'all',
|
||||
start: 0,
|
||||
length: 16,
|
||||
},
|
||||
{ success: true, result: 'raw result' },
|
||||
);
|
||||
|
||||
expect(summary).toBe('Read tracks Lead and Pads from bars 1 to 4.');
|
||||
});
|
||||
|
||||
it('includes empty MIDI tracks as rest-only ABC sections in all-track reads', async () => {
|
||||
const project = new KGProject('read-music-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const leadTrack = buildTrack('Lead', 1, 0, 8);
|
||||
const emptyTrack = new KGMidiTrack('Pads', 2);
|
||||
project.setTracks([leadTrack, emptyTrack]);
|
||||
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
} as unknown as KGCore);
|
||||
|
||||
const tool = new ReadMusicTool();
|
||||
const result = await tool.execute({ track_id: 'all', start: 0, length: 8 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toContain('track_id: 1');
|
||||
expect(result.result).toContain('track_name: Lead');
|
||||
expect(result.result).toContain('Instrument: Acoustic Grand Piano');
|
||||
expect(result.result).toContain('track_id: 2');
|
||||
expect(result.result).toContain('track_name: Pads');
|
||||
expect(result.result).toContain('Instrument: Acoustic Grand Piano');
|
||||
expect(result.result).not.toContain('Track 1 - Melody:');
|
||||
expect(result.result).not.toContain('Track 2 - Pads:');
|
||||
expect(result.result).not.toContain('\nT:');
|
||||
expect(result.result).toContain('z4 | z4 | // No regions found');
|
||||
});
|
||||
|
||||
it('returns a professional empty-project message when all MIDI tracks are empty', async () => {
|
||||
const project = new KGProject('read-music-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const emptyTrack = new KGMidiTrack('Lead', 1);
|
||||
emptyTrack.setRegions([
|
||||
new KGMidiRegion('region-1', emptyTrack.getId().toString(), emptyTrack.getTrackIndex(), 'Lead Region', 0, 36),
|
||||
]);
|
||||
project.setTracks([emptyTrack]);
|
||||
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
} as unknown as KGCore);
|
||||
|
||||
const tool = new ReadMusicTool();
|
||||
const result = await tool.execute({ track_id: 'all', start: 0, length: 36 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toBe('No musical content is present in the project yet.');
|
||||
});
|
||||
|
||||
it('returns a professional empty-range message when the selected range has no MIDI notes', async () => {
|
||||
const project = new KGProject('read-music-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const leadTrack = buildTrack('Lead', 1, 0, 8);
|
||||
const emptyTrack = new KGMidiTrack('Pads', 2);
|
||||
project.setTracks([leadTrack, emptyTrack]);
|
||||
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
} as unknown as KGCore);
|
||||
|
||||
const tool = new ReadMusicTool();
|
||||
const result = await tool.execute({ track_id: 'all', start: 16, length: 8 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toBe('No musical content was found in the selected range.');
|
||||
});
|
||||
});
|
||||
+195
-101
@@ -1,5 +1,6 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolResult, ToolParameter } from './BaseTool';
|
||||
import type { KGTrack } from '../../core/track/KGTrack';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { convertRegionToABCNotation } from '../../util/abcNotationUtil';
|
||||
@@ -32,6 +33,20 @@ export class ReadMusicTool extends BaseTool {
|
||||
}
|
||||
};
|
||||
|
||||
buildToolResultDisplayContent(args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
if (!toolResult.success || !args) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const summary = this.buildSummaryData(args);
|
||||
if (!summary) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trackLabel = summary.trackNames.length === 1 ? 'track' : 'tracks';
|
||||
return `Read ${trackLabel} ${this.formatTrackNameList(summary.trackNames)} from ${this.formatBarRange(summary.startBar, summary.endBar)}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name for percussion instruments, or null if not percussion
|
||||
*/
|
||||
@@ -120,44 +135,165 @@ export class ReadMusicTool extends BaseTool {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find tracks that should be skipped because they have no musical content
|
||||
* (no regions or regions with no notes)
|
||||
*/
|
||||
private findTracksToSkip(tracks: KGMidiTrack[]): KGMidiTrack[] {
|
||||
try {
|
||||
const tracksToSkip: KGMidiTrack[] = [];
|
||||
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
|
||||
// Skip tracks with no regions
|
||||
if (regions.length === 0) {
|
||||
tracksToSkip.push(track);
|
||||
continue;
|
||||
private buildSummaryData(args: Record<string, unknown>): {
|
||||
trackNames: string[];
|
||||
startBar: number;
|
||||
endBar: number;
|
||||
} | null {
|
||||
const project = this.getCurrentProject();
|
||||
const tracks = project.getTracks();
|
||||
if (tracks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if all regions in this track are empty (have no notes)
|
||||
const hasAnyNotes = regions.some(region => {
|
||||
if (region.getCurrentType() === 'KGMidiRegion') {
|
||||
return (region as KGMidiRegion).getNotes().length > 0;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Skip tracks where no regions have notes
|
||||
if (!hasAnyNotes) {
|
||||
tracksToSkip.push(track);
|
||||
}
|
||||
const beatsPerBar = project.getTimeSignature().numerator;
|
||||
const startBeat = (args.start as number) || 0;
|
||||
const length = args.length as number | undefined;
|
||||
if (startBeat < 0 || (length !== undefined && length <= 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tracksToSkip;
|
||||
} catch (error) {
|
||||
console.error('Error finding tracks to skip:', error);
|
||||
const roundedStartBeat = Math.floor(startBeat / beatsPerBar) * beatsPerBar;
|
||||
const rawEndBeat = length !== undefined ? startBeat + length : undefined;
|
||||
const roundedEndBeat = rawEndBeat !== undefined
|
||||
? Math.ceil(rawEndBeat / beatsPerBar) * beatsPerBar
|
||||
: this.getTrackReadEndBeat(args, tracks, roundedStartBeat);
|
||||
|
||||
const trackNames = this.resolveSummaryTrackNames(args, tracks);
|
||||
if (trackNames.length === 0 || roundedEndBeat === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
trackNames,
|
||||
startBar: Math.floor(roundedStartBeat / beatsPerBar) + 1,
|
||||
endBar: Math.max(1, Math.ceil(roundedEndBeat / beatsPerBar)),
|
||||
};
|
||||
}
|
||||
|
||||
private resolveSummaryTrackNames(
|
||||
args: Record<string, unknown>,
|
||||
tracks: KGTrack[],
|
||||
): string[] {
|
||||
const trackId = args.track_id as string | undefined;
|
||||
|
||||
if (!trackId || trackId === '' || trackId === 'all') {
|
||||
const midiTracks = tracks.filter(track => track instanceof KGMidiTrack) as KGMidiTrack[];
|
||||
return midiTracks.map((track, index) => track.getName() || `Track ${index + 1}`);
|
||||
}
|
||||
|
||||
const targetTrack = tracks.find(track => track.getId().toString() === trackId);
|
||||
if (!(targetTrack instanceof KGMidiTrack)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [targetTrack.getName() || 'Unnamed Track'];
|
||||
}
|
||||
|
||||
private getTrackReadEndBeat(
|
||||
args: Record<string, unknown>,
|
||||
tracks: KGTrack[],
|
||||
roundedStartBeat: number,
|
||||
): number | undefined {
|
||||
const trackId = args.track_id as string | undefined;
|
||||
|
||||
if (!trackId || trackId === '' || trackId === 'all') {
|
||||
const midiTracks = tracks.filter(track => track instanceof KGMidiTrack) as KGMidiTrack[];
|
||||
const endBeats = midiTracks.flatMap(track =>
|
||||
track.getRegions()
|
||||
.filter(region => region instanceof KGMidiRegion)
|
||||
.map(region => region.getStartFromBeat() + region.getLength())
|
||||
);
|
||||
return endBeats.length > 0 ? Math.max(roundedStartBeat, ...endBeats) : roundedStartBeat;
|
||||
}
|
||||
|
||||
const targetTrack = tracks.find(track => track.getId().toString() === trackId);
|
||||
if (!(targetTrack instanceof KGMidiTrack)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const endBeats = targetTrack.getRegions()
|
||||
.filter(region => region instanceof KGMidiRegion)
|
||||
.map(region => region.getStartFromBeat() + region.getLength());
|
||||
return endBeats.length > 0 ? Math.max(roundedStartBeat, ...endBeats) : roundedStartBeat;
|
||||
}
|
||||
|
||||
private formatTrackNameList(trackNames: string[]): string {
|
||||
if (trackNames.length === 1) {
|
||||
return trackNames[0];
|
||||
}
|
||||
if (trackNames.length === 2) {
|
||||
return `${trackNames[0]} and ${trackNames[1]}`;
|
||||
}
|
||||
|
||||
return `${trackNames.slice(0, -1).join(', ')}, and ${trackNames.at(-1)}`;
|
||||
}
|
||||
|
||||
private formatBarRange(startBar: number, endBar: number): string {
|
||||
return startBar === endBar
|
||||
? `bar ${startBar}`
|
||||
: `bars ${startBar} to ${endBar}`;
|
||||
}
|
||||
|
||||
private hasMidiContentInRange(track: KGMidiTrack, startBeat: number, endBeat?: number): boolean {
|
||||
const rangeEndBeat = endBeat ?? Infinity;
|
||||
|
||||
return track.getRegions().some(region => {
|
||||
if (!(region instanceof KGMidiRegion)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const regionStart = region.getStartFromBeat();
|
||||
const regionEnd = regionStart + region.getLength();
|
||||
const overlapsRange = regionStart < rangeEndBeat && regionEnd > startBeat;
|
||||
|
||||
return overlapsRange && region.getNotes().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
private getEmptyProjectMessage(): string {
|
||||
return 'No musical content is present in the project yet.';
|
||||
}
|
||||
|
||||
private getEmptyRangeMessage(): string {
|
||||
return 'No musical content was found in the selected range.';
|
||||
}
|
||||
|
||||
private buildTrackHeader(track: KGMidiTrack): string {
|
||||
const project = this.getCurrentProject();
|
||||
const timeSignature = project.getTimeSignature();
|
||||
const bpm = project.getBpm();
|
||||
const keySignature = project.getKeySignature();
|
||||
const abcKeySignature = KEY_SIGNATURE_MAP[keySignature]?.abcNotationKeySignature || 'C';
|
||||
const trackId = track.getId().toString();
|
||||
const trackName = track.getName() || 'Unnamed Track';
|
||||
const instrumentName = FLUIDR3_INSTRUMENT_MAP[track.getInstrument()]?.displayName || track.getInstrument();
|
||||
|
||||
return [
|
||||
`track_id: ${trackId}`,
|
||||
`track_name: ${trackName}`,
|
||||
`Instrument: ${instrumentName}`,
|
||||
'X:1',
|
||||
`M:${timeSignature.numerator}/${timeSignature.denominator}`,
|
||||
`L:1/${timeSignature.denominator}`,
|
||||
`Q:1/${timeSignature.denominator}=${bpm}`,
|
||||
`K:${abcKeySignature}`
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private hasAnyMidiNotes(tracks: KGMidiTrack[]): boolean {
|
||||
return tracks.some(track => track.getRegions().some(region => (
|
||||
region instanceof KGMidiRegion && region.getNotes().length > 0
|
||||
)));
|
||||
}
|
||||
|
||||
private buildRestBody(startBeat: number, endBeat: number | undefined): string {
|
||||
const beatsPerBar = this.getCurrentProject().getTimeSignature().numerator;
|
||||
const effectiveEndBeat = endBeat ?? (startBeat + beatsPerBar);
|
||||
const totalBars = Math.max(1, Math.ceil((effectiveEndBeat - startBeat) / beatsPerBar));
|
||||
const restToken = `z${beatsPerBar}`;
|
||||
return Array.from({ length: totalBars }, () => restToken).join(' | ') + ' |';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate ABC notation for all tracks
|
||||
@@ -166,53 +302,27 @@ export class ReadMusicTool extends BaseTool {
|
||||
const midiTracks = tracks.filter(track => track instanceof KGMidiTrack);
|
||||
|
||||
if (midiTracks.length === 0) {
|
||||
return 'No MIDI tracks found in the project.';
|
||||
return this.getEmptyProjectMessage();
|
||||
}
|
||||
|
||||
// Find tracks to skip (tracks with no content)
|
||||
const tracksToSkip = this.findTracksToSkip(midiTracks);
|
||||
|
||||
// Get project settings for proper notation
|
||||
const project = this.getCurrentProject();
|
||||
const timeSignature = project.getTimeSignature();
|
||||
const keySignature = project.getKeySignature();
|
||||
const abcKeySignature = KEY_SIGNATURE_MAP[keySignature]?.abcNotationKeySignature || 'C';
|
||||
|
||||
let output = `All Tracks (beats ${startBeat}-${endBeat || 'end'}):\n\n`;
|
||||
|
||||
midiTracks.forEach((track, index) => {
|
||||
// Skip tracks that have no musical content
|
||||
if (tracksToSkip.includes(track)) {
|
||||
return; // Skip this track
|
||||
}
|
||||
const trackNumber = index + 1;
|
||||
const trackName = track.getName() || `Track ${trackNumber}`;
|
||||
|
||||
// Check if this track uses a percussion instrument
|
||||
const percussionDisplayName = this.getPercussionDisplayName(track);
|
||||
|
||||
let displayTrackName: string;
|
||||
if (percussionDisplayName) {
|
||||
// Use percussion instrument display name for all percussion tracks
|
||||
displayTrackName = percussionDisplayName;
|
||||
} else if (trackNumber === 1) {
|
||||
// Use "Melody" for the first non-percussion track
|
||||
displayTrackName = 'Melody';
|
||||
} else {
|
||||
// Use original track name for other non-percussion tracks
|
||||
displayTrackName = trackName;
|
||||
if (!this.hasAnyMidiNotes(midiTracks)) {
|
||||
return this.getEmptyProjectMessage();
|
||||
}
|
||||
|
||||
output += `Track ${trackNumber} - ${displayTrackName}:\n`;
|
||||
const hasContentInRange = midiTracks.some(track => this.hasMidiContentInRange(track, startBeat, endBeat));
|
||||
if (!hasContentInRange) {
|
||||
return this.getEmptyRangeMessage();
|
||||
}
|
||||
|
||||
let output = `Tracks (beats ${startBeat}-${endBeat || 'end'}):\n\n`;
|
||||
|
||||
midiTracks.forEach((track) => {
|
||||
// Get all regions from the track and convert each one
|
||||
const regions = track.getRegions().filter(region => region instanceof KGMidiRegion) as KGMidiRegion[];
|
||||
|
||||
if (regions.length === 0) {
|
||||
output += 'X:' + trackNumber + '\n';
|
||||
output += `M:${timeSignature.numerator}/${timeSignature.denominator}\n`;
|
||||
output += `K:${abcKeySignature}\n`;
|
||||
output += 'z4 | // No regions found\n\n';
|
||||
output += `${this.buildTrackHeader(track)}\n`;
|
||||
output += `${this.buildRestBody(startBeat, endBeat)} // No regions found\n\n`;
|
||||
} else {
|
||||
// Convert each region that overlaps with the requested range
|
||||
let hasContent = false;
|
||||
@@ -223,20 +333,14 @@ export class ReadMusicTool extends BaseTool {
|
||||
// Check if region overlaps with requested range
|
||||
if (regionStart < (endBeat || Infinity) && regionEnd > startBeat) {
|
||||
const abcNotation = convertRegionToABCNotation(region, startBeat, endBeat);
|
||||
|
||||
// Update the X: line to include track number
|
||||
const lines = abcNotation.split('\n');
|
||||
lines[0] = `X:${trackNumber}`;
|
||||
output += lines.join('\n') + '\n\n';
|
||||
output += abcNotation + '\n\n';
|
||||
hasContent = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasContent) {
|
||||
output += 'X:' + trackNumber + '\n';
|
||||
output += `M:${timeSignature.numerator}/${timeSignature.denominator}\n`;
|
||||
output += `K:${abcKeySignature}\n`;
|
||||
output += 'z4 | // No content in specified range\n\n';
|
||||
output += `${this.buildTrackHeader(track)}\n`;
|
||||
output += `${this.buildRestBody(startBeat, endBeat)} // No content in specified range\n\n`;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -252,26 +356,24 @@ export class ReadMusicTool extends BaseTool {
|
||||
return `Track is not a MIDI track.`;
|
||||
}
|
||||
|
||||
// Get project settings for proper notation
|
||||
const project = this.getCurrentProject();
|
||||
const timeSignature = project.getTimeSignature();
|
||||
const keySignature = project.getKeySignature();
|
||||
const abcKeySignature = KEY_SIGNATURE_MAP[keySignature]?.abcNotationKeySignature || 'C';
|
||||
if (!track.getRegions().some(region => (
|
||||
region instanceof KGMidiRegion && region.getNotes().length > 0
|
||||
))) {
|
||||
return this.getEmptyProjectMessage();
|
||||
}
|
||||
|
||||
const trackName = track.getName() || 'Unnamed Track';
|
||||
if (!this.hasMidiContentInRange(track, startBeat, endBeat)) {
|
||||
return this.getEmptyRangeMessage();
|
||||
}
|
||||
|
||||
let output = `Track "${trackName}" (beats ${startBeat}-${endBeat || 'end'}):\n`;
|
||||
let output = '';
|
||||
|
||||
// Get all regions from the track and convert each one
|
||||
const regions = track.getRegions().filter(region => region instanceof KGMidiRegion) as KGMidiRegion[];
|
||||
|
||||
if (regions.length === 0) {
|
||||
output += 'X:1\n';
|
||||
output += `T:${trackName}\n`;
|
||||
output += `M:${timeSignature.numerator}/${timeSignature.denominator}\n`;
|
||||
output += `K:${abcKeySignature}\n`;
|
||||
output += `L:1/${timeSignature.denominator}\n`;
|
||||
output += 'z4 | // No regions found';
|
||||
output += `${this.buildTrackHeader(track)}\n`;
|
||||
output += `${this.buildRestBody(startBeat, endBeat)} // No regions found`;
|
||||
} else {
|
||||
// Convert each region that overlaps with the requested range
|
||||
let hasContent = false;
|
||||
@@ -282,22 +384,14 @@ export class ReadMusicTool extends BaseTool {
|
||||
// Check if region overlaps with requested range
|
||||
if (regionStart < (endBeat || Infinity) && regionEnd > startBeat) {
|
||||
const abcNotation = convertRegionToABCNotation(region, startBeat, endBeat);
|
||||
|
||||
// Update the title to include track name
|
||||
const lines = abcNotation.split('\n');
|
||||
lines[1] = `T:${trackName}`;
|
||||
output += lines.join('\n');
|
||||
output += abcNotation;
|
||||
hasContent = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasContent) {
|
||||
output += 'X:1\n';
|
||||
output += `T:${trackName}\n`;
|
||||
output += `M:${timeSignature.numerator}/${timeSignature.denominator}\n`;
|
||||
output += `K:${abcKeySignature}\n`;
|
||||
output += `L:1/${timeSignature.denominator}\n`;
|
||||
output += 'z4 | // No content in specified range';
|
||||
output += `${this.buildTrackHeader(track)}\n`;
|
||||
output += `${this.buildRestBody(startBeat, endBeat)} // No content in specified range`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RemoveBpmTool } from './RemoveBpmTool';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGTempoRegion } from '../../core/region/KGTempoRegion';
|
||||
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
|
||||
import { GlobalTrackType } from '../../core/global-track';
|
||||
|
||||
function mockCore(project: KGProject) {
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [],
|
||||
executeCommand: (command: { execute(): void }) => command.execute(),
|
||||
removeSelectedItem: vi.fn(),
|
||||
} as unknown as KGCore);
|
||||
}
|
||||
|
||||
function getTempoTrack(project: KGProject) {
|
||||
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||
expect(track).not.toBeNull();
|
||||
return track!;
|
||||
}
|
||||
|
||||
describe('RemoveBpmTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('is only available in regular mode', () => {
|
||||
const tool = new RemoveBpmTool();
|
||||
expect(tool.isReadOnlyTool()).toBe(false);
|
||||
expect(tool.isAvailableInEfficientMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('removes a single exact start-beat match when start equals end', async () => {
|
||||
const project = new KGProject('exact-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 });
|
||||
const track = getTempoTrack(project);
|
||||
track.setRegions([
|
||||
new KGTempoRegion('tempo-1', track.getId(), track.getTrackIndex(), 120, 0, 2, 4),
|
||||
new KGTempoRegion('tempo-2', track.getId(), track.getTrackIndex(), 140, 2, 6, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveBpmTool();
|
||||
const result = await tool.execute({ start: 8, end: 8 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect((track.getRegions() as KGTempoRegion[]).map(region => ({
|
||||
bpm: region.getBpm(),
|
||||
startBar: region.getStartBar(),
|
||||
lengthBars: region.getLengthBars(),
|
||||
}))).toEqual([
|
||||
{ bpm: 120, startBar: 0, lengthBars: 8 },
|
||||
]);
|
||||
expect(tool.buildToolHistoryContent({}, result)).toBe(result.result);
|
||||
expect(tool.buildToolResultDisplayContent({}, result)).toBe(result.result);
|
||||
});
|
||||
|
||||
it('removes multiple BPM regions and preserves gapless collapse', async () => {
|
||||
const project = new KGProject('range-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 });
|
||||
const track = getTempoTrack(project);
|
||||
track.setRegions([
|
||||
new KGTempoRegion('tempo-1', track.getId(), track.getTrackIndex(), 120, 0, 2, 4),
|
||||
new KGTempoRegion('tempo-2', track.getId(), track.getTrackIndex(), 128, 2, 2, 4),
|
||||
new KGTempoRegion('tempo-3', track.getId(), track.getTrackIndex(), 140, 4, 4, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveBpmTool();
|
||||
const result = await tool.execute({ start: 8, end: 20 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect((track.getRegions() as KGTempoRegion[]).map(region => ({
|
||||
bpm: region.getBpm(),
|
||||
startBar: region.getStartBar(),
|
||||
lengthBars: region.getLengthBars(),
|
||||
}))).toEqual([
|
||||
{ bpm: 120, startBar: 0, lengthBars: 8 },
|
||||
]);
|
||||
expect(result.result).toContain('"128 BPM" at beat 8');
|
||||
expect(result.result).toContain('"140 BPM" at beat 16');
|
||||
});
|
||||
|
||||
it('returns a successful message when no BPM regions match the range', async () => {
|
||||
const project = new KGProject('none-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 });
|
||||
const track = getTempoTrack(project);
|
||||
track.setRegions([
|
||||
new KGTempoRegion('tempo-1', track.getId(), track.getTrackIndex(), 120, 0, 8, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveBpmTool();
|
||||
const result = await tool.execute({ start: 12, end: 16 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toContain('No BPM regions found');
|
||||
});
|
||||
|
||||
it('deletes the only remaining explicit BPM region', async () => {
|
||||
const project = new KGProject('single-region-project', 8, 0, 120, { numerator: 4, denominator: 4 });
|
||||
const track = getTempoTrack(project);
|
||||
track.setRegions([
|
||||
new KGTempoRegion('tempo-1', track.getId(), track.getTrackIndex(), 132, 0, 8, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveBpmTool();
|
||||
const result = await tool.execute({ start: 0, end: 0 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(track.getRegions()).toHaveLength(0);
|
||||
expect(project.getBpm()).toBe(120);
|
||||
});
|
||||
|
||||
it('builds a confirmation summary for the affected bar span', () => {
|
||||
const project = new KGProject('confirmation-project', 8, 0, 120, { numerator: 4, denominator: 4 });
|
||||
const track = getTempoTrack(project);
|
||||
track.setRegions([
|
||||
new KGTempoRegion('tempo-1', track.getId(), track.getTrackIndex(), 128, 2, 2, 4),
|
||||
new KGTempoRegion('tempo-2', track.getId(), track.getTrackIndex(), 140, 4, 4, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveBpmTool();
|
||||
expect(tool.buildConfirmationContent({ start: 8, end: 20 }))
|
||||
.toBe('Allow removing 2 BPM regions from the global Tempo Track across bars 3 to 8?');
|
||||
});
|
||||
|
||||
it('rejects invalid beat ranges', async () => {
|
||||
const project = new KGProject('bad-range-project', 8, 0, 120, { numerator: 4, denominator: 4 });
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveBpmTool();
|
||||
const result = await tool.execute({ start: 8, end: 4 });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('must be greater than or equal to start');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import { DeleteMultipleTempoRegionsCommand } from '../../core/commands/global-region/DeleteTempoRegionCommand';
|
||||
import { GlobalTrackType } from '../../core/global-track';
|
||||
import { KGTempoRegion } from '../../core/region/KGTempoRegion';
|
||||
import { findGlobalTrackByType, getSortedTempoRegions } from '../../util/globalTrackUtil';
|
||||
|
||||
interface BpmRemovalSummaryData {
|
||||
regionCount: number;
|
||||
startBeat: number;
|
||||
endBeat: number;
|
||||
firstBar: number;
|
||||
lastBar: number;
|
||||
}
|
||||
|
||||
export class RemoveBpmTool extends BaseTool {
|
||||
readonly name = 'remove_bpm';
|
||||
readonly description = 'Remove BPM regions from the global Tempo track by absolute start-beat range. This removes whole tempo regions whose start beat falls within the requested range.';
|
||||
|
||||
override isReadOnlyTool(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override isAvailableInEfficientMode(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
start: {
|
||||
type: 'number',
|
||||
description: 'Start beat — the absolute beat position where the removal range begins. When start is less than end, regions starting exactly at this beat are removed.',
|
||||
required: true,
|
||||
},
|
||||
end: {
|
||||
type: 'number',
|
||||
description: 'End beat — the absolute beat position where the removal range ends. When start is less than end, this value is exclusive. When start equals end, only the region starting exactly at that beat is removed.',
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
override buildToolResultDisplayContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
return toolResult.result;
|
||||
}
|
||||
|
||||
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
return toolResult.result;
|
||||
}
|
||||
|
||||
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
|
||||
if (!args) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const summary = this.buildSummaryData(args);
|
||||
if (!summary) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `Allow removing ${summary.regionCount} BPM ${summary.regionCount === 1 ? 'region' : 'regions'} from the global Tempo Track across ${summary.firstBar === summary.lastBar ? `bar ${summary.firstBar}` : `bars ${summary.firstBar} to ${summary.lastBar}`}?`;
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
this.validateParameters(params);
|
||||
|
||||
const startBeat = params.start as number;
|
||||
const endBeat = params.end as number;
|
||||
this.validateRange(startBeat, endBeat);
|
||||
|
||||
const matchingRegions = this.findMatchingRegions(startBeat, endBeat);
|
||||
if (matchingRegions.length === 0) {
|
||||
return this.createSuccessResult(
|
||||
`No BPM regions found with start beats in the requested range from beat ${startBeat} to ${endBeat}.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.executeCommand(new DeleteMultipleTempoRegionsCommand(matchingRegions.map(region => region.getId())));
|
||||
|
||||
const details = matchingRegions
|
||||
.map(region => `"${region.getBpm()} BPM" at beat ${region.getStartFromBeat()}`)
|
||||
.join(', ');
|
||||
return this.createSuccessResult(
|
||||
`Successfully removed ${matchingRegions.length} BPM ${matchingRegions.length === 1 ? 'region' : 'regions'} from the global Tempo track: ${details}.`,
|
||||
);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to remove BPM: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private validateRange(startBeat: number, endBeat: number): void {
|
||||
if (startBeat < 0) {
|
||||
throw new Error(`Invalid start ${startBeat}. Must be >= 0.`);
|
||||
}
|
||||
if (endBeat < startBeat) {
|
||||
throw new Error(`Invalid beat range: end (${endBeat}) must be greater than or equal to start (${startBeat}).`);
|
||||
}
|
||||
}
|
||||
|
||||
private findMatchingRegions(startBeat: number, endBeat: number): KGTempoRegion[] {
|
||||
const project = this.getCurrentProject();
|
||||
const tempoTrack = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||
if (!tempoTrack) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const beatsPerBar = project.getTimeSignature().numerator;
|
||||
return getSortedTempoRegions(tempoTrack, beatsPerBar)
|
||||
.filter(region => this.matchesRange(region.getStartFromBeat(), startBeat, endBeat));
|
||||
}
|
||||
|
||||
private matchesRange(regionStartBeat: number, startBeat: number, endBeat: number): boolean {
|
||||
if (startBeat === endBeat) {
|
||||
return regionStartBeat === startBeat;
|
||||
}
|
||||
|
||||
return regionStartBeat >= startBeat && regionStartBeat < endBeat;
|
||||
}
|
||||
|
||||
private buildSummaryData(args: Record<string, unknown>): BpmRemovalSummaryData | null {
|
||||
const typedArgs = args as { start?: number; end?: number };
|
||||
if (typeof typedArgs.start !== 'number' || typeof typedArgs.end !== 'number') {
|
||||
return null;
|
||||
}
|
||||
if (typedArgs.start < 0 || typedArgs.end < typedArgs.start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matchingRegions = this.findMatchingRegions(typedArgs.start, typedArgs.end);
|
||||
const beatsPerBar = this.getCurrentProject().getTimeSignature().numerator;
|
||||
|
||||
if (matchingRegions.length === 0) {
|
||||
const bar = Math.floor(typedArgs.start / beatsPerBar) + 1;
|
||||
return {
|
||||
regionCount: 0,
|
||||
startBeat: typedArgs.start,
|
||||
endBeat: typedArgs.end,
|
||||
firstBar: bar,
|
||||
lastBar: bar,
|
||||
};
|
||||
}
|
||||
|
||||
const firstBeat = Math.min(...matchingRegions.map(region => region.getStartFromBeat()));
|
||||
const lastBeat = Math.max(...matchingRegions.map(region => region.getStartFromBeat() + region.getLength()));
|
||||
return {
|
||||
regionCount: matchingRegions.length,
|
||||
startBeat: typedArgs.start,
|
||||
endBeat: typedArgs.end,
|
||||
firstBar: Math.floor(firstBeat / beatsPerBar) + 1,
|
||||
lastBar: Math.max(1, Math.ceil(lastBeat / beatsPerBar)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RemoveChordProgressionTool } from './RemoveChordProgressionTool';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGChordRegion } from '../../core/region/KGChordRegion';
|
||||
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
|
||||
import { GlobalTrackType } from '../../core/global-track';
|
||||
|
||||
function mockCore(project: KGProject) {
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [],
|
||||
executeCommand: (command: { execute(): void }) => command.execute(),
|
||||
removeSelectedItem: vi.fn(),
|
||||
} as unknown as KGCore);
|
||||
}
|
||||
|
||||
function getChordTrack(project: KGProject) {
|
||||
const track = findGlobalTrackByType(project, GlobalTrackType.Chord);
|
||||
expect(track).not.toBeNull();
|
||||
return track!;
|
||||
}
|
||||
|
||||
describe('RemoveChordProgressionTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('is only available in regular mode', () => {
|
||||
const tool = new RemoveChordProgressionTool();
|
||||
expect(tool.isReadOnlyTool()).toBe(false);
|
||||
expect(tool.isAvailableInEfficientMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('removes a single exact start-beat match when start equals end', async () => {
|
||||
const project = new KGProject('exact-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const chordTrack = getChordTrack(project);
|
||||
chordTrack.setRegions([
|
||||
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'C', 0, 4),
|
||||
new KGChordRegion('chord-2', chordTrack.getId(), chordTrack.getTrackIndex(), 'G', 4, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveChordProgressionTool();
|
||||
const result = await tool.execute({ start: 4, end: 4 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect((chordTrack.getRegions() as KGChordRegion[]).map(region => region.getSymbol())).toEqual(['C']);
|
||||
expect(result.result).toContain('"G" at beat 4');
|
||||
});
|
||||
|
||||
it('removes multiple chord regions in a start-inclusive, end-exclusive range', async () => {
|
||||
const project = new KGProject('range-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const chordTrack = getChordTrack(project);
|
||||
chordTrack.setRegions([
|
||||
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'C', 0, 4),
|
||||
new KGChordRegion('chord-2', chordTrack.getId(), chordTrack.getTrackIndex(), 'Dm', 4, 4),
|
||||
new KGChordRegion('chord-3', chordTrack.getId(), chordTrack.getTrackIndex(), 'G', 8, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveChordProgressionTool();
|
||||
const result = await tool.execute({ start: 4, end: 8 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect((chordTrack.getRegions() as KGChordRegion[]).map(region => region.getSymbol())).toEqual(['C', 'G']);
|
||||
expect(tool.buildToolHistoryContent({}, result)).toBe(result.result);
|
||||
expect(tool.buildToolResultDisplayContent({ start: 4, end: 8 }, result))
|
||||
.toBe('Removed 1 chord reference from the global Chord Track across bar 2.');
|
||||
});
|
||||
|
||||
it('returns a successful message when no chord references match the range', async () => {
|
||||
const project = new KGProject('none-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const chordTrack = getChordTrack(project);
|
||||
chordTrack.setRegions([
|
||||
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'C', 0, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveChordProgressionTool();
|
||||
const result = await tool.execute({ start: 12, end: 16 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toContain('No chord references found');
|
||||
expect(tool.buildToolResultDisplayContent({ start: 12, end: 16 }, result))
|
||||
.toBe('No chord references found for removal at beats 12-16.');
|
||||
});
|
||||
|
||||
it('builds a confirmation summary for the affected bar span', () => {
|
||||
const project = new KGProject('confirmation-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const chordTrack = getChordTrack(project);
|
||||
chordTrack.setRegions([
|
||||
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Dm', 4, 4),
|
||||
new KGChordRegion('chord-2', chordTrack.getId(), chordTrack.getTrackIndex(), 'G', 8, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveChordProgressionTool();
|
||||
expect(tool.buildConfirmationContent({ start: 4, end: 12 }))
|
||||
.toBe('Allow removing 2 chord references from the global Chord Track across bars 2 to 3?');
|
||||
});
|
||||
|
||||
it('rejects invalid beat ranges', async () => {
|
||||
const project = new KGProject('bad-range-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveChordProgressionTool();
|
||||
const result = await tool.execute({ start: 8, end: 4 });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('must be greater than or equal to start');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import { DeleteMultipleGlobalRegionsCommand } from '../../core/commands/global-region/DeleteGlobalRegionCommand';
|
||||
import { GlobalTrackType } from '../../core/global-track';
|
||||
import { KGChordRegion } from '../../core/region/KGChordRegion';
|
||||
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
|
||||
|
||||
interface ChordRemovalSummaryData {
|
||||
chordCount: number;
|
||||
startBeat: number;
|
||||
endBeat: number;
|
||||
firstBar: number;
|
||||
lastBar: number;
|
||||
}
|
||||
|
||||
export class RemoveChordProgressionTool extends BaseTool {
|
||||
readonly name = 'remove_chord_progression';
|
||||
readonly description = 'Remove chord-reference regions from the global Chord track by absolute start-beat range. This removes whole chord-reference regions whose start beat falls within the requested range.';
|
||||
|
||||
override isReadOnlyTool(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override isAvailableInEfficientMode(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
start: {
|
||||
type: 'number',
|
||||
description: 'Start beat — the absolute beat position where the removal range begins. When start is less than end, regions starting exactly at this beat are removed.',
|
||||
required: true,
|
||||
},
|
||||
end: {
|
||||
type: 'number',
|
||||
description: 'End beat — the absolute beat position where the removal range ends. When start is less than end, this value is exclusive. When start equals end, only regions starting exactly at that beat are removed.',
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
override buildToolResultDisplayContent(args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
if (!args || !toolResult.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (toolResult.result.startsWith('No chord references found')) {
|
||||
const summary = this.buildSummaryData(args);
|
||||
return summary
|
||||
? `No chord references found for removal at beats ${summary.startBeat}-${summary.endBeat}.`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const summary = this.buildResultSummaryData(args, toolResult.result);
|
||||
if (!summary) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `Removed ${summary.chordCount} chord ${summary.chordCount === 1 ? 'reference' : 'references'} from the global Chord Track across ${summary.firstBar === summary.lastBar ? `bar ${summary.firstBar}` : `bars ${summary.firstBar} to ${summary.lastBar}`}.`;
|
||||
}
|
||||
|
||||
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
return toolResult.result;
|
||||
}
|
||||
|
||||
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
|
||||
if (!args) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const summary = this.buildSummaryData(args);
|
||||
if (!summary) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `Allow removing ${summary.chordCount} chord ${summary.chordCount === 1 ? 'reference' : 'references'} from the global Chord Track across ${summary.firstBar === summary.lastBar ? `bar ${summary.firstBar}` : `bars ${summary.firstBar} to ${summary.lastBar}`}?`;
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
this.validateParameters(params);
|
||||
|
||||
const startBeat = params.start as number;
|
||||
const endBeat = params.end as number;
|
||||
this.validateRange(startBeat, endBeat);
|
||||
|
||||
const matchingRegions = this.findMatchingRegions(startBeat, endBeat);
|
||||
if (matchingRegions.length === 0) {
|
||||
return this.createSuccessResult(
|
||||
`No chord references found with start beats in the requested range from beat ${startBeat} to ${endBeat}.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.executeCommand(new DeleteMultipleGlobalRegionsCommand(matchingRegions.map(region => region.getId())));
|
||||
|
||||
const details = matchingRegions
|
||||
.map(region => `"${region.getSymbol()}" at beat ${region.getStartFromBeat()}`)
|
||||
.join(', ');
|
||||
return this.createSuccessResult(
|
||||
`Successfully removed ${matchingRegions.length} chord ${matchingRegions.length === 1 ? 'reference' : 'references'} from the global chord track: ${details}.`,
|
||||
);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to remove chord progression: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private validateRange(startBeat: number, endBeat: number): void {
|
||||
if (startBeat < 0) {
|
||||
throw new Error(`Invalid start ${startBeat}. Must be >= 0.`);
|
||||
}
|
||||
if (endBeat < startBeat) {
|
||||
throw new Error(`Invalid beat range: end (${endBeat}) must be greater than or equal to start (${startBeat}).`);
|
||||
}
|
||||
}
|
||||
|
||||
private findMatchingRegions(startBeat: number, endBeat: number): KGChordRegion[] {
|
||||
const project = this.getCurrentProject();
|
||||
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
|
||||
if (!chordTrack) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return chordTrack.getRegions()
|
||||
.filter((region): region is KGChordRegion => region instanceof KGChordRegion)
|
||||
.filter(region => this.matchesRange(region.getStartFromBeat(), startBeat, endBeat))
|
||||
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
|
||||
}
|
||||
|
||||
private matchesRange(regionStartBeat: number, startBeat: number, endBeat: number): boolean {
|
||||
if (startBeat === endBeat) {
|
||||
return regionStartBeat === startBeat;
|
||||
}
|
||||
|
||||
return regionStartBeat >= startBeat && regionStartBeat < endBeat;
|
||||
}
|
||||
|
||||
private buildSummaryData(args: Record<string, unknown>): ChordRemovalSummaryData | null {
|
||||
const typedArgs = args as { start?: number; end?: number };
|
||||
if (typeof typedArgs.start !== 'number' || typeof typedArgs.end !== 'number') {
|
||||
return null;
|
||||
}
|
||||
if (typedArgs.start < 0 || typedArgs.end < typedArgs.start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matchingRegions = this.findMatchingRegions(typedArgs.start, typedArgs.end);
|
||||
const beatsPerBar = this.getCurrentProject().getTimeSignature().numerator;
|
||||
|
||||
if (matchingRegions.length === 0) {
|
||||
const bar = Math.floor(typedArgs.start / beatsPerBar) + 1;
|
||||
return {
|
||||
chordCount: 0,
|
||||
startBeat: typedArgs.start,
|
||||
endBeat: typedArgs.end,
|
||||
firstBar: bar,
|
||||
lastBar: bar,
|
||||
};
|
||||
}
|
||||
|
||||
const firstBeat = Math.min(...matchingRegions.map(region => region.getStartFromBeat()));
|
||||
const lastBeat = Math.max(...matchingRegions.map(region => region.getStartFromBeat() + region.getLength()));
|
||||
return {
|
||||
chordCount: matchingRegions.length,
|
||||
startBeat: typedArgs.start,
|
||||
endBeat: typedArgs.end,
|
||||
firstBar: Math.floor(firstBeat / beatsPerBar) + 1,
|
||||
lastBar: Math.max(1, Math.ceil(lastBeat / beatsPerBar)),
|
||||
};
|
||||
}
|
||||
|
||||
private buildResultSummaryData(args: Record<string, unknown>, resultText: string): ChordRemovalSummaryData | null {
|
||||
const typedArgs = args as { start?: number; end?: number };
|
||||
if (typeof typedArgs.start !== 'number' || typeof typedArgs.end !== 'number') {
|
||||
return null;
|
||||
}
|
||||
if (typedArgs.start < 0 || typedArgs.end < typedArgs.start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const countMatch = resultText.match(/Successfully removed (\d+) chord/);
|
||||
if (!countMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const beatsPerBar = this.getCurrentProject().getTimeSignature().numerator;
|
||||
const firstBar = Math.floor(typedArgs.start / beatsPerBar) + 1;
|
||||
const lastBeatExclusive = typedArgs.start === typedArgs.end
|
||||
? typedArgs.start + 1
|
||||
: typedArgs.end;
|
||||
const lastBar = Math.max(1, Math.ceil(lastBeatExclusive / beatsPerBar));
|
||||
|
||||
return {
|
||||
chordCount: Number(countMatch[1]),
|
||||
startBeat: typedArgs.start,
|
||||
endBeat: typedArgs.end,
|
||||
firstBar,
|
||||
lastBar,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RemoveKeySignatureTool } from './RemoveKeySignatureTool';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
|
||||
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
|
||||
import { GlobalTrackType } from '../../core/global-track';
|
||||
|
||||
function mockCore(project: KGProject) {
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [],
|
||||
executeCommand: (command: { execute(): void }) => command.execute(),
|
||||
removeSelectedItem: vi.fn(),
|
||||
} as unknown as KGCore);
|
||||
}
|
||||
|
||||
function getSignatureTrack(project: KGProject) {
|
||||
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
|
||||
expect(track).not.toBeNull();
|
||||
return track!;
|
||||
}
|
||||
|
||||
describe('RemoveKeySignatureTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('is only available in regular mode', () => {
|
||||
const tool = new RemoveKeySignatureTool();
|
||||
expect(tool.isReadOnlyTool()).toBe(false);
|
||||
expect(tool.isAvailableInEfficientMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('removes a single exact start-beat match when start equals end', async () => {
|
||||
const project = new KGProject('exact-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const track = getSignatureTrack(project);
|
||||
track.setRegions([
|
||||
new KGKeySignatureRegion('sig-1', track.getId(), track.getTrackIndex(), 'C major', 0, 2, 4),
|
||||
new KGKeySignatureRegion('sig-2', track.getId(), track.getTrackIndex(), 'G major', 2, 6, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveKeySignatureTool();
|
||||
const result = await tool.execute({ start: 8, end: 8 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect((track.getRegions() as KGKeySignatureRegion[]).map(region => ({
|
||||
key: region.getKeySignature(),
|
||||
startBar: region.getStartBar(),
|
||||
lengthBars: region.getLengthBars(),
|
||||
}))).toEqual([
|
||||
{ key: 'C major', startBar: 0, lengthBars: 8 },
|
||||
]);
|
||||
expect(tool.buildToolHistoryContent({}, result)).toBe(result.result);
|
||||
expect(tool.buildToolResultDisplayContent({}, result)).toBe(result.result);
|
||||
});
|
||||
|
||||
it('removes multiple key-signature regions and preserves gapless collapse', async () => {
|
||||
const project = new KGProject('range-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const track = getSignatureTrack(project);
|
||||
track.setRegions([
|
||||
new KGKeySignatureRegion('sig-1', track.getId(), track.getTrackIndex(), 'C major', 0, 2, 4),
|
||||
new KGKeySignatureRegion('sig-2', track.getId(), track.getTrackIndex(), 'G major', 2, 2, 4),
|
||||
new KGKeySignatureRegion('sig-3', track.getId(), track.getTrackIndex(), 'D major', 4, 4, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveKeySignatureTool();
|
||||
const result = await tool.execute({ start: 8, end: 20 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect((track.getRegions() as KGKeySignatureRegion[]).map(region => ({
|
||||
key: region.getKeySignature(),
|
||||
startBar: region.getStartBar(),
|
||||
lengthBars: region.getLengthBars(),
|
||||
}))).toEqual([
|
||||
{ key: 'C major', startBar: 0, lengthBars: 8 },
|
||||
]);
|
||||
expect(result.result).toContain('"G major" at beat 8');
|
||||
expect(result.result).toContain('"D major" at beat 16');
|
||||
});
|
||||
|
||||
it('returns a successful message when no key-signature regions match the range', async () => {
|
||||
const project = new KGProject('none-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const track = getSignatureTrack(project);
|
||||
track.setRegions([
|
||||
new KGKeySignatureRegion('sig-1', track.getId(), track.getTrackIndex(), 'C major', 0, 8, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveKeySignatureTool();
|
||||
const result = await tool.execute({ start: 12, end: 16 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toContain('No key-signature regions found');
|
||||
});
|
||||
|
||||
it('deletes the only remaining key-signature region', async () => {
|
||||
const project = new KGProject('single-region-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const track = getSignatureTrack(project);
|
||||
track.setRegions([
|
||||
new KGKeySignatureRegion('sig-1', track.getId(), track.getTrackIndex(), 'E minor', 0, 8, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveKeySignatureTool();
|
||||
const result = await tool.execute({ start: 0, end: 0 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(track.getRegions()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('builds a confirmation summary for the affected bar span', () => {
|
||||
const project = new KGProject('confirmation-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const track = getSignatureTrack(project);
|
||||
track.setRegions([
|
||||
new KGKeySignatureRegion('sig-1', track.getId(), track.getTrackIndex(), 'G major', 2, 2, 4),
|
||||
new KGKeySignatureRegion('sig-2', track.getId(), track.getTrackIndex(), 'D major', 4, 4, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveKeySignatureTool();
|
||||
expect(tool.buildConfirmationContent({ start: 8, end: 20 }))
|
||||
.toBe('Allow removing 2 key signature regions from the global Signature Track across bars 3 to 8?');
|
||||
});
|
||||
|
||||
it('rejects invalid beat ranges', async () => {
|
||||
const project = new KGProject('bad-range-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveKeySignatureTool();
|
||||
const result = await tool.execute({ start: 8, end: 4 });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('must be greater than or equal to start');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import { DeleteMultipleKeySignatureRegionsCommand } from '../../core/commands/global-region/DeleteKeySignatureRegionCommand';
|
||||
import { GlobalTrackType } from '../../core/global-track';
|
||||
import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
|
||||
import { findGlobalTrackByType, getSortedKeySignatureRegions } from '../../util/globalTrackUtil';
|
||||
|
||||
interface KeySignatureRemovalSummaryData {
|
||||
regionCount: number;
|
||||
startBeat: number;
|
||||
endBeat: number;
|
||||
firstBar: number;
|
||||
lastBar: number;
|
||||
}
|
||||
|
||||
export class RemoveKeySignatureTool extends BaseTool {
|
||||
readonly name = 'remove_key_signature';
|
||||
readonly description = 'Remove key-signature regions from the global Signature track by absolute start-beat range. This removes whole key-signature regions whose start beat falls within the requested range.';
|
||||
|
||||
override isReadOnlyTool(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override isAvailableInEfficientMode(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
start: {
|
||||
type: 'number',
|
||||
description: 'Start beat — the absolute beat position where the removal range begins. When start is less than end, regions starting exactly at this beat are removed.',
|
||||
required: true,
|
||||
},
|
||||
end: {
|
||||
type: 'number',
|
||||
description: 'End beat — the absolute beat position where the removal range ends. When start is less than end, this value is exclusive. When start equals end, only regions starting exactly at that beat are removed.',
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
override buildToolResultDisplayContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
return toolResult.result;
|
||||
}
|
||||
|
||||
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
return toolResult.result;
|
||||
}
|
||||
|
||||
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
|
||||
if (!args) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const summary = this.buildSummaryData(args);
|
||||
if (!summary) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `Allow removing ${summary.regionCount} key signature ${summary.regionCount === 1 ? 'region' : 'regions'} from the global Signature Track across ${summary.firstBar === summary.lastBar ? `bar ${summary.firstBar}` : `bars ${summary.firstBar} to ${summary.lastBar}`}?`;
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
this.validateParameters(params);
|
||||
|
||||
const startBeat = params.start as number;
|
||||
const endBeat = params.end as number;
|
||||
this.validateRange(startBeat, endBeat);
|
||||
|
||||
const matchingRegions = this.findMatchingRegions(startBeat, endBeat);
|
||||
if (matchingRegions.length === 0) {
|
||||
return this.createSuccessResult(
|
||||
`No key-signature regions found with start beats in the requested range from beat ${startBeat} to ${endBeat}.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.executeCommand(new DeleteMultipleKeySignatureRegionsCommand(matchingRegions.map(region => region.getId())));
|
||||
|
||||
const details = matchingRegions
|
||||
.map(region => `"${region.getKeySignature()}" at beat ${region.getStartFromBeat()}`)
|
||||
.join(', ');
|
||||
return this.createSuccessResult(
|
||||
`Successfully removed ${matchingRegions.length} key signature ${matchingRegions.length === 1 ? 'region' : 'regions'} from the global Signature track: ${details}.`,
|
||||
);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to remove key signature: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private validateRange(startBeat: number, endBeat: number): void {
|
||||
if (startBeat < 0) {
|
||||
throw new Error(`Invalid start ${startBeat}. Must be >= 0.`);
|
||||
}
|
||||
if (endBeat < startBeat) {
|
||||
throw new Error(`Invalid beat range: end (${endBeat}) must be greater than or equal to start (${startBeat}).`);
|
||||
}
|
||||
}
|
||||
|
||||
private findMatchingRegions(startBeat: number, endBeat: number): KGKeySignatureRegion[] {
|
||||
const project = this.getCurrentProject();
|
||||
const signatureTrack = findGlobalTrackByType(project, GlobalTrackType.Signature);
|
||||
if (!signatureTrack) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const beatsPerBar = project.getTimeSignature().numerator;
|
||||
return getSortedKeySignatureRegions(signatureTrack, beatsPerBar)
|
||||
.filter(region => this.matchesRange(region.getStartFromBeat(), startBeat, endBeat));
|
||||
}
|
||||
|
||||
private matchesRange(regionStartBeat: number, startBeat: number, endBeat: number): boolean {
|
||||
if (startBeat === endBeat) {
|
||||
return regionStartBeat === startBeat;
|
||||
}
|
||||
|
||||
return regionStartBeat >= startBeat && regionStartBeat < endBeat;
|
||||
}
|
||||
|
||||
private buildSummaryData(args: Record<string, unknown>): KeySignatureRemovalSummaryData | null {
|
||||
const typedArgs = args as { start?: number; end?: number };
|
||||
if (typeof typedArgs.start !== 'number' || typeof typedArgs.end !== 'number') {
|
||||
return null;
|
||||
}
|
||||
if (typedArgs.start < 0 || typedArgs.end < typedArgs.start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matchingRegions = this.findMatchingRegions(typedArgs.start, typedArgs.end);
|
||||
const beatsPerBar = this.getCurrentProject().getTimeSignature().numerator;
|
||||
|
||||
if (matchingRegions.length === 0) {
|
||||
const bar = Math.floor(typedArgs.start / beatsPerBar) + 1;
|
||||
return {
|
||||
regionCount: 0,
|
||||
startBeat: typedArgs.start,
|
||||
endBeat: typedArgs.end,
|
||||
firstBar: bar,
|
||||
lastBar: bar,
|
||||
};
|
||||
}
|
||||
|
||||
const firstBeat = Math.min(...matchingRegions.map(region => region.getStartFromBeat()));
|
||||
const lastBeat = Math.max(...matchingRegions.map(region => region.getStartFromBeat() + region.getLength()));
|
||||
return {
|
||||
regionCount: matchingRegions.length,
|
||||
startBeat: typedArgs.start,
|
||||
endBeat: typedArgs.end,
|
||||
firstBar: Math.floor(firstBeat / beatsPerBar) + 1,
|
||||
lastBar: Math.max(1, Math.ceil(lastBeat / beatsPerBar)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RemoveMarkersTool } from './RemoveMarkersTool';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGMarkerRegion } from '../../core/region/KGMarkerRegion';
|
||||
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
|
||||
import { GlobalTrackType } from '../../core/global-track';
|
||||
|
||||
function mockCore(project: KGProject) {
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [],
|
||||
executeCommand: (command: { execute(): void }) => command.execute(),
|
||||
removeSelectedItem: vi.fn(),
|
||||
} as unknown as KGCore);
|
||||
}
|
||||
|
||||
function getMarkerTrack(project: KGProject) {
|
||||
const track = findGlobalTrackByType(project, GlobalTrackType.Marker);
|
||||
expect(track).not.toBeNull();
|
||||
return track!;
|
||||
}
|
||||
|
||||
describe('RemoveMarkersTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('is only available in regular mode', () => {
|
||||
const tool = new RemoveMarkersTool();
|
||||
expect(tool.isReadOnlyTool()).toBe(false);
|
||||
expect(tool.isAvailableInEfficientMode()).toBe(false);
|
||||
});
|
||||
|
||||
it('removes a single exact start-beat match when start equals end', async () => {
|
||||
const project = new KGProject('exact-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const markerTrack = getMarkerTrack(project);
|
||||
markerTrack.setRegions([
|
||||
new KGMarkerRegion('marker-1', markerTrack.getId(), markerTrack.getTrackIndex(), 'Intro', 0, 4),
|
||||
new KGMarkerRegion('marker-2', markerTrack.getId(), markerTrack.getTrackIndex(), 'Verse', 4, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveMarkersTool();
|
||||
const result = await tool.execute({ start: 4, end: 4 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect((markerTrack.getRegions() as KGMarkerRegion[]).map(region => region.getName())).toEqual(['Intro']);
|
||||
expect(result.result).toContain('[Beat: 4; Length: 4]: Verse');
|
||||
});
|
||||
|
||||
it('removes multiple marker regions in a start-inclusive, end-exclusive range', async () => {
|
||||
const project = new KGProject('range-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const markerTrack = getMarkerTrack(project);
|
||||
markerTrack.setRegions([
|
||||
new KGMarkerRegion('marker-1', markerTrack.getId(), markerTrack.getTrackIndex(), 'Intro', 0, 4),
|
||||
new KGMarkerRegion('marker-2', markerTrack.getId(), markerTrack.getTrackIndex(), 'Verse', 4, 4),
|
||||
new KGMarkerRegion('marker-3', markerTrack.getId(), markerTrack.getTrackIndex(), 'Chorus', 8, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveMarkersTool();
|
||||
const result = await tool.execute({ start: 4, end: 8 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect((markerTrack.getRegions() as KGMarkerRegion[]).map(region => region.getName())).toEqual(['Intro', 'Chorus']);
|
||||
expect(tool.buildToolHistoryContent({}, result)).toBe(result.result);
|
||||
expect(tool.buildToolResultDisplayContent({}, result)).toContain('Successfully removed 1 marker annotation');
|
||||
});
|
||||
|
||||
it('returns a successful message when no markers match the range', async () => {
|
||||
const project = new KGProject('none-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const markerTrack = getMarkerTrack(project);
|
||||
markerTrack.setRegions([
|
||||
new KGMarkerRegion('marker-1', markerTrack.getId(), markerTrack.getTrackIndex(), 'Intro', 0, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveMarkersTool();
|
||||
const result = await tool.execute({ start: 12, end: 16 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toContain('No marker annotations found');
|
||||
});
|
||||
|
||||
it('rejects invalid beat ranges', async () => {
|
||||
const project = new KGProject('bad-range-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveMarkersTool();
|
||||
const result = await tool.execute({ start: 8, end: 4 });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('must be greater than or equal to start');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import { DeleteMultipleGlobalRegionsCommand } from '../../core/commands/global-region/DeleteGlobalRegionCommand';
|
||||
import { GlobalTrackType } from '../../core/global-track';
|
||||
import { KGMarkerRegion } from '../../core/region/KGMarkerRegion';
|
||||
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
|
||||
|
||||
export class RemoveMarkersTool extends BaseTool {
|
||||
readonly name = 'remove_markers';
|
||||
readonly description = 'Remove marker annotations from the global Marker track by region start beat. This deletes whole marker regions whose start beat is in the requested range. Markers are annotations only and do not affect playback.';
|
||||
|
||||
override isReadOnlyTool(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override isAvailableInEfficientMode(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
start: {
|
||||
type: 'number',
|
||||
description: 'Start beat where the removal range begins. When start is less than end, markers starting exactly at this beat are removed.',
|
||||
required: true,
|
||||
},
|
||||
end: {
|
||||
type: 'number',
|
||||
description: 'End beat where the removal range ends. When start is less than end, this value is exclusive. When start equals end, only the marker starting exactly at that beat is removed.',
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
override buildToolResultDisplayContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
return this.formatMultilineResult(toolResult.result);
|
||||
}
|
||||
|
||||
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
return toolResult.result;
|
||||
}
|
||||
|
||||
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
|
||||
if (!args) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const typedArgs = args as { start?: number; end?: number };
|
||||
if (typeof typedArgs.start !== 'number' || typeof typedArgs.end !== 'number') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `Allow removing marker annotations from the global Marker track in the start-beat range ${typedArgs.start} to ${typedArgs.end}?`;
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
this.validateParameters(params);
|
||||
|
||||
const startBeat = params.start as number;
|
||||
const endBeat = params.end as number;
|
||||
this.validateRange(startBeat, endBeat);
|
||||
|
||||
const matchingRegions = this.findMatchingRegions(startBeat, endBeat);
|
||||
if (matchingRegions.length === 0) {
|
||||
return this.createSuccessResult(
|
||||
`No marker annotations found with start beats in the requested range from beat ${startBeat} to ${endBeat}.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.executeCommand(new DeleteMultipleGlobalRegionsCommand(matchingRegions.map(region => region.getId())));
|
||||
|
||||
const details = matchingRegions
|
||||
.map(region => `[Beat: ${region.getStartFromBeat()}; Length: ${region.getLength()}]: ${region.getName()}`)
|
||||
.join('\n');
|
||||
return this.createSuccessResult(
|
||||
`Successfully removed ${matchingRegions.length} marker ${matchingRegions.length === 1 ? 'annotation' : 'annotations'} from the global Marker track. Markers are annotation-only and do not affect playback.\n${details}`,
|
||||
);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to remove markers: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private validateRange(startBeat: number, endBeat: number): void {
|
||||
if (startBeat < 0) {
|
||||
throw new Error(`Invalid start ${startBeat}. Must be >= 0.`);
|
||||
}
|
||||
if (endBeat < startBeat) {
|
||||
throw new Error(`Invalid beat range: end (${endBeat}) must be greater than or equal to start (${startBeat}).`);
|
||||
}
|
||||
}
|
||||
|
||||
private findMatchingRegions(startBeat: number, endBeat: number): KGMarkerRegion[] {
|
||||
const project = this.getCurrentProject();
|
||||
const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
|
||||
if (!markerTrack) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return markerTrack.getRegions()
|
||||
.filter((region): region is KGMarkerRegion => region instanceof KGMarkerRegion)
|
||||
.filter(region => this.matchesRange(region.getStartFromBeat(), startBeat, endBeat))
|
||||
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
|
||||
}
|
||||
|
||||
private matchesRange(regionStartBeat: number, startBeat: number, endBeat: number): boolean {
|
||||
if (startBeat === endBeat) {
|
||||
return regionStartBeat === startBeat;
|
||||
}
|
||||
|
||||
return regionStartBeat >= startBeat && regionStartBeat < endBeat;
|
||||
}
|
||||
|
||||
private formatMultilineResult(result: string): string {
|
||||
return result.replace(/\n/g, ' \n');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RemoveNotesTool } from './RemoveNotesTool';
|
||||
import {
|
||||
NO_MIDI_TARGET_HISTORY_MESSAGE,
|
||||
NO_MIDI_TARGET_RAW_MESSAGE,
|
||||
NO_MIDI_TARGET_UI_MESSAGE,
|
||||
} from './toolTargeting';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
||||
|
||||
const storeState = {
|
||||
activeRegionId: null as string | null,
|
||||
};
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: {
|
||||
getState: () => storeState,
|
||||
},
|
||||
}));
|
||||
|
||||
function mockCore(project: KGProject, selectedItems: unknown[] = []) {
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => selectedItems,
|
||||
executeCommand: (command: { execute(): void }) => command.execute(),
|
||||
} as unknown as KGCore);
|
||||
}
|
||||
|
||||
describe('RemoveNotesTool', () => {
|
||||
beforeEach(() => {
|
||||
storeState.activeRegionId = null;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('builds confirmation and result summaries for region-scoped removal', () => {
|
||||
const track = new KGMidiTrack('Lead', 1);
|
||||
const region = new KGMidiRegion('region-1', track.getId().toString(), track.getTrackIndex(), 'Verse Melody', 0, 32);
|
||||
region.setNotes([
|
||||
new KGMidiNote('note-1', 16, 20, 60, 100),
|
||||
new KGMidiNote('note-2', 20, 28, 64, 100),
|
||||
]);
|
||||
track.setRegions([region]);
|
||||
const project = new KGProject('summary-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
project.setTracks([track]);
|
||||
storeState.activeRegionId = region.getId();
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveNotesTool();
|
||||
const args = { start: 16, end: 24 };
|
||||
|
||||
expect(tool.buildConfirmationContent(args)).toBe(
|
||||
'Allow removing 2 notes from beats 16-24, in region **Verse Melody** on track **Lead**, spanning bars 5 to 7?',
|
||||
);
|
||||
expect(tool.buildToolResultDisplayContent(args, { success: true, result: 'raw result' })).toBe(
|
||||
'Successfully removed 2 notes from beats 16-24, in region **Verse Melody** on track **Lead**, spanning bars 5 to 7.',
|
||||
);
|
||||
});
|
||||
|
||||
it('removes notes across all MIDI regions on a track when track_id is provided', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1);
|
||||
const regionA = new KGMidiRegion('region-a', track.getId().toString(), track.getTrackIndex(), 'A', 0, 8);
|
||||
const regionB = new KGMidiRegion('region-b', track.getId().toString(), track.getTrackIndex(), 'B', 8, 8);
|
||||
regionA.setNotes([new KGMidiNote('note-1', 2, 3, 60, 100)]);
|
||||
regionB.setNotes([new KGMidiNote('note-2', 2, 3, 64, 100)]);
|
||||
track.setRegions([regionA, regionB]);
|
||||
const project = new KGProject('track-remove-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveNotesTool();
|
||||
const result = await tool.execute({
|
||||
track_id: track.getId().toString(),
|
||||
start: 0,
|
||||
end: 12,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(regionA.getNotes()).toHaveLength(0);
|
||||
expect(regionB.getNotes()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('removes notes across a track resolved by track_name when track_id is omitted', async () => {
|
||||
const leadTrack = new KGMidiTrack('Lead', 1);
|
||||
const bassTrack = new KGMidiTrack('Bass', 2);
|
||||
const leadRegion = new KGMidiRegion('lead-region', leadTrack.getId().toString(), leadTrack.getTrackIndex(), 'Lead Region', 0, 8);
|
||||
const bassRegion = new KGMidiRegion('bass-region', bassTrack.getId().toString(), bassTrack.getTrackIndex(), 'Bass Region', 0, 8);
|
||||
leadRegion.setNotes([new KGMidiNote('lead-note', 1, 2, 60, 100)]);
|
||||
bassRegion.setNotes([new KGMidiNote('bass-note', 1, 2, 48, 100)]);
|
||||
leadTrack.setRegions([leadRegion]);
|
||||
bassTrack.setRegions([bassRegion]);
|
||||
const project = new KGProject('remove-track-name-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
project.setTracks([leadTrack, bassTrack]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveNotesTool();
|
||||
const result = await tool.execute({
|
||||
track_name: 'Bass',
|
||||
start: 0,
|
||||
end: 4,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(leadRegion.getNotes()).toHaveLength(1);
|
||||
expect(bassRegion.getNotes()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses track_id when both track_id and track_name are provided', async () => {
|
||||
const leadTrack = new KGMidiTrack('Lead', 1);
|
||||
const bassTrack = new KGMidiTrack('Bass', 2);
|
||||
const leadRegion = new KGMidiRegion('lead-region', leadTrack.getId().toString(), leadTrack.getTrackIndex(), 'Lead Region', 0, 8);
|
||||
const bassRegion = new KGMidiRegion('bass-region', bassTrack.getId().toString(), bassTrack.getTrackIndex(), 'Bass Region', 0, 8);
|
||||
leadRegion.setNotes([new KGMidiNote('lead-note', 1, 2, 60, 100)]);
|
||||
bassRegion.setNotes([new KGMidiNote('bass-note', 1, 2, 48, 100)]);
|
||||
leadTrack.setRegions([leadRegion]);
|
||||
bassTrack.setRegions([bassRegion]);
|
||||
const project = new KGProject('remove-track-id-precedence-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
project.setTracks([leadTrack, bassTrack]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveNotesTool();
|
||||
const result = await tool.execute({
|
||||
track_id: leadTrack.getId().toString(),
|
||||
track_name: 'Bass',
|
||||
start: 0,
|
||||
end: 4,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(leadRegion.getNotes()).toHaveLength(0);
|
||||
expect(bassRegion.getNotes()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('uses the first matching track when duplicate track names exist', async () => {
|
||||
const firstLead = new KGMidiTrack('Lead', 1);
|
||||
const secondLead = new KGMidiTrack('Lead', 2);
|
||||
const firstRegion = new KGMidiRegion('first-region', firstLead.getId().toString(), firstLead.getTrackIndex(), 'First Lead Region', 0, 8);
|
||||
const secondRegion = new KGMidiRegion('second-region', secondLead.getId().toString(), secondLead.getTrackIndex(), 'Second Lead Region', 0, 8);
|
||||
firstRegion.setNotes([new KGMidiNote('first-note', 1, 2, 60, 100)]);
|
||||
secondRegion.setNotes([new KGMidiNote('second-note', 1, 2, 64, 100)]);
|
||||
firstLead.setRegions([firstRegion]);
|
||||
secondLead.setRegions([secondRegion]);
|
||||
const project = new KGProject('remove-duplicate-track-name-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
project.setTracks([firstLead, secondLead]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveNotesTool();
|
||||
const result = await tool.execute({
|
||||
track_name: 'Lead',
|
||||
start: 0,
|
||||
end: 4,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(firstRegion.getNotes()).toHaveLength(0);
|
||||
expect(secondRegion.getNotes()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns distinct raw, history, and UI guidance when no MIDI target is available', async () => {
|
||||
const project = new KGProject('no-target-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new RemoveNotesTool();
|
||||
const args = { start: 0, end: 4 };
|
||||
const result = await tool.execute(args);
|
||||
|
||||
expect(result).toEqual({ success: false, result: NO_MIDI_TARGET_RAW_MESSAGE });
|
||||
expect(tool.buildToolHistoryContent(args, result)).toBe(NO_MIDI_TARGET_HISTORY_MESSAGE);
|
||||
expect(tool.buildToolResultDisplayContent(args, result)).toBe(NO_MIDI_TARGET_UI_MESSAGE);
|
||||
});
|
||||
});
|
||||
+237
-108
@@ -1,168 +1,297 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolResult, ToolParameter } from './BaseTool';
|
||||
import {
|
||||
NO_MIDI_TARGET_HISTORY_MESSAGE,
|
||||
NO_MIDI_TARGET_RAW_MESSAGE,
|
||||
NO_MIDI_TARGET_UI_MESSAGE,
|
||||
getTrackDisplayName,
|
||||
resolveMidiTrackByIdOrName,
|
||||
resolveActiveOrSelectedMidiRegionContext,
|
||||
} from './toolTargeting';
|
||||
import { DeleteNotesCommand } from '../../core/commands/note/DeleteNotesCommand';
|
||||
import { KGMidiNote } from '../../core/midi/KGMidiNote';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
|
||||
interface RemoveTargetRegionContext {
|
||||
region: KGMidiRegion;
|
||||
trackName: string;
|
||||
}
|
||||
|
||||
interface RemoveNotesSummaryData {
|
||||
noteCount: number;
|
||||
startBeat: number;
|
||||
endBeat: number;
|
||||
regionName?: string;
|
||||
trackName: string;
|
||||
earliestNoteStartBar: number;
|
||||
latestNoteEndBar: number;
|
||||
scope: 'region' | 'track';
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for removing notes from MIDI regions within a specified beat range
|
||||
* Integrates with the existing command system for undo/redo support
|
||||
*/
|
||||
export class RemoveNotesTool extends BaseTool {
|
||||
readonly name = 'remove_notes';
|
||||
readonly description = 'Remove all MIDI notes whose start position falls within the specified beat range. Use this to clear a section before rewriting it, or to delete unwanted notes. Beat positions are absolute on the project timeline.';
|
||||
readonly description = 'Remove MIDI notes from an absolute beat range. Use track_id to remove notes across every MIDI region on a track. If track_id is omitted, the currently active or selected MIDI region is used.';
|
||||
|
||||
override isReadOnlyTool(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
start: {
|
||||
type: 'number',
|
||||
description: 'Start beat — the absolute beat position where the removal range begins (inclusive). A note starting at exactly this beat will be removed.',
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
end: {
|
||||
type: 'number',
|
||||
description: 'End beat — the absolute beat position where the removal range ends (exclusive). A note starting at exactly this beat will NOT be removed. Must be greater than start.',
|
||||
required: true
|
||||
required: true,
|
||||
},
|
||||
region_id: {
|
||||
track_id: {
|
||||
type: 'string',
|
||||
description: 'Target region ID. If omitted, uses the currently active piano roll region or selected region.',
|
||||
required: false
|
||||
}
|
||||
description: 'Optional target MIDI track ID. If provided, matching notes are removed across all MIDI regions on that track whose absolute start positions fall within the requested beat range.',
|
||||
required: false,
|
||||
},
|
||||
track_name: {
|
||||
type: 'string',
|
||||
description: 'Optional target MIDI track name. Used only when track_id is omitted. If multiple MIDI tracks share the same name, the first matching track is used.',
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
|
||||
override buildToolResultDisplayContent(args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
if (!args) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!toolResult.success) {
|
||||
return toolResult.result === NO_MIDI_TARGET_RAW_MESSAGE ? NO_MIDI_TARGET_UI_MESSAGE : undefined;
|
||||
}
|
||||
|
||||
const summary = this.buildSummaryData(args);
|
||||
if (!summary || summary.noteCount === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const location = summary.scope === 'track'
|
||||
? `on track **${summary.trackName}**`
|
||||
: `in region **${summary.regionName}** on track **${summary.trackName}**`;
|
||||
return `Successfully removed ${summary.noteCount} ${summary.noteCount === 1 ? 'note' : 'notes'} from beats ${summary.startBeat}-${summary.endBeat}, ${location}, spanning bars ${summary.earliestNoteStartBar} to ${summary.latestNoteEndBar}.`;
|
||||
}
|
||||
|
||||
override buildToolHistoryContent(args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
if (!args || toolResult.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return toolResult.result === NO_MIDI_TARGET_RAW_MESSAGE ? NO_MIDI_TARGET_HISTORY_MESSAGE : undefined;
|
||||
}
|
||||
|
||||
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
|
||||
if (!args) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const summary = this.buildSummaryData(args);
|
||||
if (!summary) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const location = summary.scope === 'track'
|
||||
? `on track **${summary.trackName}**`
|
||||
: `in region **${summary.regionName}** on track **${summary.trackName}**`;
|
||||
return `Allow removing ${summary.noteCount} ${summary.noteCount === 1 ? 'note' : 'notes'} from beats ${summary.startBeat}-${summary.endBeat}, ${location}, spanning bars ${summary.earliestNoteStartBar} to ${summary.latestNoteEndBar}?`;
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
// Validate parameters
|
||||
this.validateParameters(params);
|
||||
|
||||
const startBeat = params.start as number;
|
||||
const endBeat = params.end as number;
|
||||
const regionId = params.region_id as string | undefined;
|
||||
const trackId = params.track_id as string | undefined;
|
||||
const trackName = params.track_name as string | undefined;
|
||||
|
||||
// Validate beat range
|
||||
if (startBeat < 0) {
|
||||
return this.createErrorResult(`Invalid start ${startBeat}. Must be >= 0.`);
|
||||
}
|
||||
|
||||
if (endBeat <= startBeat) {
|
||||
return this.createErrorResult(`Invalid beat range: end (${endBeat}) must be greater than start (${startBeat}).`);
|
||||
}
|
||||
|
||||
// Find the target region
|
||||
const targetRegion = this.findTargetRegion(regionId);
|
||||
if (!targetRegion) {
|
||||
if (trackId || trackName) {
|
||||
const explicitTrack = resolveMidiTrackByIdOrName(trackId, trackName);
|
||||
if (!explicitTrack) {
|
||||
return this.createErrorResult(
|
||||
regionId
|
||||
? `Region with ID "${regionId}" not found or is not a MIDI region`
|
||||
: 'No active or selected MIDI region found. Please open the piano roll with a region or select a MIDI region first.'
|
||||
trackId
|
||||
? `Track with ID "${trackId}" not found or is not a MIDI track.`
|
||||
: `Track with name "${trackName}" not found or is not a MIDI track.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Adjust beat range relative to region's start beat
|
||||
const regionStartBeat = targetRegion.getStartFromBeat();
|
||||
const adjustedStartBeat = startBeat - regionStartBeat;
|
||||
const adjustedEndBeat = endBeat - regionStartBeat;
|
||||
|
||||
// Find all notes within the specified beat range
|
||||
const notesToRemove = this.findNotesInRange(targetRegion, adjustedStartBeat, adjustedEndBeat);
|
||||
|
||||
if (notesToRemove.length === 0) {
|
||||
return this.createSuccessResult(
|
||||
`No notes found in the range from beat ${startBeat} to ${endBeat}.`
|
||||
);
|
||||
}
|
||||
|
||||
// Extract note IDs for deletion
|
||||
const noteIds = notesToRemove.map(note => note.getId());
|
||||
const notesToRemove = (trackId || trackName)
|
||||
? this.findTrackNotesInRange(trackId, trackName, startBeat, endBeat)
|
||||
: this.findFallbackRegionNotesInRange(startBeat, endBeat);
|
||||
|
||||
// Execute the deletion command
|
||||
const command = new DeleteNotesCommand(noteIds);
|
||||
await this.executeCommand(command);
|
||||
if (!notesToRemove) {
|
||||
return this.createErrorResult(NO_MIDI_TARGET_RAW_MESSAGE);
|
||||
}
|
||||
|
||||
// Create success message
|
||||
const noteCount = notesToRemove.length;
|
||||
const noteList = notesToRemove
|
||||
.map(note => {
|
||||
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const octave = Math.floor(note.getPitch() / 12) - 1;
|
||||
const noteName = noteNames[note.getPitch() % 12];
|
||||
return `${noteName}${octave}`;
|
||||
})
|
||||
.join(', ');
|
||||
if (notesToRemove.notes.length === 0) {
|
||||
return this.createSuccessResult(`No notes found in the range from beat ${startBeat} to ${endBeat}.`);
|
||||
}
|
||||
|
||||
const noteIds = notesToRemove.notes.map(note => note.getId());
|
||||
await this.executeCommand(new DeleteNotesCommand(noteIds));
|
||||
|
||||
const noteList = notesToRemove.notes.map(note => this.formatMidiPitch(note.getPitch())).join(', ');
|
||||
const scopeLabel = notesToRemove.scope === 'track'
|
||||
? `track "${notesToRemove.trackName}"`
|
||||
: `MIDI region "${notesToRemove.regionName}" on track "${notesToRemove.trackName}"`;
|
||||
return this.createSuccessResult(
|
||||
`Successfully removed ${noteCount} note${noteCount > 1 ? 's' : ''} from beats ${startBeat}-${endBeat}: ${noteList}`
|
||||
`Successfully removed ${notesToRemove.notes.length} note${notesToRemove.notes.length > 1 ? 's' : ''} from beats ${startBeat}-${endBeat} in ${scopeLabel}: ${noteList}`,
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to remove notes: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the target region for note removal
|
||||
* Priority: 1) Specified regionId, 2) Active piano roll region, 3) Selected regions, 4) Error if none found
|
||||
*/
|
||||
private findTargetRegion(regionId?: string): KGMidiRegion | null {
|
||||
const project = this.getCurrentProject();
|
||||
const tracks = project.getTracks();
|
||||
private buildSummaryData(args: Record<string, unknown>): RemoveNotesSummaryData | null {
|
||||
const typedArgs = args as {
|
||||
start?: number;
|
||||
end?: number;
|
||||
track_id?: string;
|
||||
track_name?: string;
|
||||
};
|
||||
|
||||
if (regionId) {
|
||||
// Find specific region by ID
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === regionId);
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
// Smart region finding: try different sources in priority order
|
||||
|
||||
// 1. Try active piano roll region
|
||||
const storeState = useProjectStore.getState();
|
||||
if (storeState.activeRegionId) {
|
||||
for (const track of tracks) {
|
||||
const regions = track.getRegions();
|
||||
const region = regions.find(r => r.getId() === storeState.activeRegionId);
|
||||
if (region && region instanceof KGMidiRegion) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try selected regions
|
||||
const core = this.getKGCore();
|
||||
const selectedItems = core.getSelectedItems();
|
||||
for (const item of selectedItems) {
|
||||
if (item instanceof KGMidiRegion) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. No fallback - return null to trigger error
|
||||
if (typeof typedArgs.start !== 'number' || typeof typedArgs.end !== 'number' || typedArgs.end <= typedArgs.start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const notesInRange = (typedArgs.track_id || typedArgs.track_name)
|
||||
? this.findTrackNotesInRange(typedArgs.track_id, typedArgs.track_name, typedArgs.start, typedArgs.end)
|
||||
: this.findFallbackRegionNotesInRange(typedArgs.start, typedArgs.end);
|
||||
|
||||
if (!notesInRange) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get KGCore instance for selection access
|
||||
*/
|
||||
private getKGCore() {
|
||||
return KGCore.instance();
|
||||
const beatsPerBar = this.getCurrentProject().getTimeSignature().numerator;
|
||||
let earliestBeat = typedArgs.start;
|
||||
let latestBeat = typedArgs.end;
|
||||
|
||||
if (notesInRange.notes.length > 0) {
|
||||
earliestBeat = Math.min(...notesInRange.notes.map(note => notesInRange.absoluteBoundsByNoteId.get(note.getId())!.startBeat));
|
||||
latestBeat = Math.max(...notesInRange.notes.map(note => notesInRange.absoluteBoundsByNoteId.get(note.getId())!.endBeat));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all notes within the specified beat range
|
||||
* Notes are included if their start beat is within [startBeat, endBeat)
|
||||
*/
|
||||
private findNotesInRange(region: KGMidiRegion, startBeat: number, endBeat: number) {
|
||||
const notes = region.getNotes();
|
||||
return notes.filter(note => {
|
||||
return {
|
||||
noteCount: notesInRange.notes.length,
|
||||
startBeat: typedArgs.start,
|
||||
endBeat: typedArgs.end,
|
||||
regionName: notesInRange.scope === 'region' ? notesInRange.regionName : undefined,
|
||||
trackName: notesInRange.trackName,
|
||||
earliestNoteStartBar: Math.floor(earliestBeat / beatsPerBar) + 1,
|
||||
latestNoteEndBar: Math.max(1, Math.ceil(latestBeat / beatsPerBar)),
|
||||
scope: notesInRange.scope,
|
||||
};
|
||||
}
|
||||
|
||||
private findFallbackRegionNotesInRange(startBeat: number, endBeat: number): {
|
||||
scope: 'region';
|
||||
trackName: string;
|
||||
regionName: string;
|
||||
notes: KGMidiNote[];
|
||||
absoluteBoundsByNoteId: Map<string, { startBeat: number; endBeat: number }>;
|
||||
} | null {
|
||||
const resolvedRegion = this.resolveFallbackRegion();
|
||||
if (!resolvedRegion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const regionStartBeat = resolvedRegion.region.getStartFromBeat();
|
||||
const adjustedStartBeat = startBeat - regionStartBeat;
|
||||
const adjustedEndBeat = endBeat - regionStartBeat;
|
||||
const notes = resolvedRegion.region.getNotes().filter(note => {
|
||||
const noteStartBeat = note.getStartBeat();
|
||||
return noteStartBeat >= startBeat && noteStartBeat < endBeat;
|
||||
return noteStartBeat >= adjustedStartBeat && noteStartBeat < adjustedEndBeat;
|
||||
});
|
||||
|
||||
return {
|
||||
scope: 'region',
|
||||
trackName: resolvedRegion.trackName,
|
||||
regionName: resolvedRegion.region.getName(),
|
||||
notes,
|
||||
absoluteBoundsByNoteId: new Map(notes.map(note => ([
|
||||
note.getId(),
|
||||
{
|
||||
startBeat: note.getStartBeat() + regionStartBeat,
|
||||
endBeat: note.getEndBeat() + regionStartBeat,
|
||||
},
|
||||
]))),
|
||||
};
|
||||
}
|
||||
|
||||
private findTrackNotesInRange(trackId: string | undefined, trackName: string | undefined, startBeat: number, endBeat: number): {
|
||||
scope: 'track';
|
||||
trackName: string;
|
||||
notes: KGMidiNote[];
|
||||
absoluteBoundsByNoteId: Map<string, { startBeat: number; endBeat: number }>;
|
||||
} | null {
|
||||
const track = resolveMidiTrackByIdOrName(trackId, trackName);
|
||||
if (!track) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const notes: KGMidiNote[] = [];
|
||||
const absoluteBoundsByNoteId = new Map<string, { startBeat: number; endBeat: number }>();
|
||||
|
||||
for (const region of track.getRegions()) {
|
||||
if (!(region instanceof KGMidiRegion)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const regionStartBeat = region.getStartFromBeat();
|
||||
for (const note of region.getNotes()) {
|
||||
const absoluteStartBeat = regionStartBeat + note.getStartBeat();
|
||||
if (absoluteStartBeat < startBeat || absoluteStartBeat >= endBeat) {
|
||||
continue;
|
||||
}
|
||||
|
||||
notes.push(note);
|
||||
absoluteBoundsByNoteId.set(note.getId(), {
|
||||
startBeat: absoluteStartBeat,
|
||||
endBeat: regionStartBeat + note.getEndBeat(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scope: 'track',
|
||||
trackName: getTrackDisplayName(track),
|
||||
notes,
|
||||
absoluteBoundsByNoteId,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveFallbackRegion(): RemoveTargetRegionContext | null {
|
||||
const resolved = resolveActiveOrSelectedMidiRegionContext();
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
region: resolved.region,
|
||||
trackName: resolved.trackName,
|
||||
};
|
||||
}
|
||||
|
||||
private formatMidiPitch(midiPitch: number): string {
|
||||
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const octave = Math.floor(midiPitch / 12) - 1;
|
||||
const noteName = noteNames[midiPitch % 12];
|
||||
return `${noteName}${octave}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: {
|
||||
getState: () => ({
|
||||
refreshProjectState: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { AgentCore } from '../core/AgentCore';
|
||||
import { UpdateTodoListTool } from './UpdateTodoListTool';
|
||||
|
||||
describe('UpdateTodoListTool', () => {
|
||||
beforeEach(() => {
|
||||
AgentCore.instance().clearConversation();
|
||||
});
|
||||
|
||||
it('accepts a valid full-list replacement and updates agent state', async () => {
|
||||
const tool = new UpdateTodoListTool();
|
||||
|
||||
const result = await tool.execute({
|
||||
items: [
|
||||
{ id: '1', text: 'Inspect current region', status: 'completed' },
|
||||
{ id: '2', text: 'Draft harmony', status: 'in_progress', activeText: 'Drafting harmony' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result).toContain('(1/2 completed)');
|
||||
expect(AgentCore.instance().getAgentState().getTodos()).toEqual([
|
||||
expect.objectContaining({ id: '1', text: 'Inspect current region', status: 'completed' }),
|
||||
expect.objectContaining({ id: '2', text: 'Draft harmony', status: 'in_progress', activeText: 'Drafting harmony' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects empty todo text', async () => {
|
||||
const tool = new UpdateTodoListTool();
|
||||
|
||||
const result = await tool.execute({
|
||||
items: [
|
||||
{ id: '1', text: ' ', status: 'pending' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('text is required');
|
||||
});
|
||||
|
||||
it('rejects invalid statuses', async () => {
|
||||
const tool = new UpdateTodoListTool();
|
||||
|
||||
const result = await tool.execute({
|
||||
items: [
|
||||
{ id: '1', text: 'Task', status: 'active' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain("invalid status 'active'");
|
||||
});
|
||||
|
||||
it('rejects duplicate ids', async () => {
|
||||
const tool = new UpdateTodoListTool();
|
||||
|
||||
const result = await tool.execute({
|
||||
items: [
|
||||
{ id: '1', text: 'Task A', status: 'pending' },
|
||||
{ id: '1', text: 'Task B', status: 'pending' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('duplicate id');
|
||||
});
|
||||
|
||||
it('rejects multiple in-progress items', async () => {
|
||||
const tool = new UpdateTodoListTool();
|
||||
|
||||
const result = await tool.execute({
|
||||
items: [
|
||||
{ id: '1', text: 'Task A', status: 'in_progress' },
|
||||
{ id: '2', text: 'Task B', status: 'in_progress' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('Only one todo item can be in_progress');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { AgentCore } from '../core/AgentCore';
|
||||
import { renderTodoList, summarizeTodoCounts, validateAndNormalizeTodos, type TodoInputItem } from '../core/todo';
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
|
||||
export class UpdateTodoListTool extends BaseTool {
|
||||
readonly name = 'update_todo_list';
|
||||
readonly description = 'Replace the current task checklist for multi-step work and keep progress updated.';
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
items: {
|
||||
type: 'array',
|
||||
description: 'The full todo list to keep for the current task.',
|
||||
required: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
description: 'A single todo item.',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Stable task id.',
|
||||
},
|
||||
text: {
|
||||
type: 'string',
|
||||
description: 'User-visible task description.',
|
||||
required: true,
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'Current task status.',
|
||||
required: true,
|
||||
enum: ['pending', 'in_progress', 'completed'],
|
||||
},
|
||||
activeText: {
|
||||
type: 'string',
|
||||
description: 'Optional present-tense wording to show while the task is in progress.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
this.validateParameters(params);
|
||||
|
||||
const items = (params.items as TodoInputItem[]) ?? [];
|
||||
const todos = validateAndNormalizeTodos(items);
|
||||
AgentCore.instance().getAgentState().setTodos(todos);
|
||||
|
||||
const counts = summarizeTodoCounts(todos);
|
||||
const rendered = renderTodoList(todos);
|
||||
|
||||
return this.createSuccessResult(
|
||||
`${rendered}\n\nTotal: ${counts.total}, in progress: ${counts.inProgress}, pending: ${counts.pending}, completed: ${counts.completed}`,
|
||||
);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(error instanceof Error ? error.message : 'Failed to update todo list');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { UpdateTrackTool } from './UpdateTrackTool';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { KGAudioTrack } from '../../core/track/KGAudioTrack';
|
||||
import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface';
|
||||
|
||||
vi.mock('../../stores/projectStore', () => ({
|
||||
useProjectStore: {
|
||||
getState: () => ({
|
||||
activeRegionId: null,
|
||||
selectedRegionIds: [],
|
||||
selectedTrackId: null,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
function mockCore(project: KGProject) {
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
executeCommand: (command: { execute(): void }) => command.execute(),
|
||||
} as unknown as KGCore);
|
||||
}
|
||||
|
||||
describe('UpdateTrackTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(KGAudioInterface, 'instance').mockReturnValue({
|
||||
setTrackInstrument: vi.fn(),
|
||||
} as unknown as KGAudioInterface);
|
||||
});
|
||||
|
||||
it('renames a track by track_id', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const project = new KGProject('rename-track-project');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '1',
|
||||
new_track_name: 'Lead 2',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
result: 'Track updated:\ntrack_id: 1\ntrack_name: Lead 2\ninstrument: Trumpet',
|
||||
});
|
||||
expect(track.getName()).toBe('Lead 2');
|
||||
expect(tool.buildToolResultDisplayContent({
|
||||
track_id: '1',
|
||||
new_track_name: 'Lead 2',
|
||||
}, result)).toBe(
|
||||
'Track updated:\n- track_id: 1\n- track_name: Lead 2\n- instrument: Trumpet',
|
||||
);
|
||||
});
|
||||
|
||||
it('updates a track instrument by track_name', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const project = new KGProject('instrument-track-project');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_name: 'Lead',
|
||||
instrument: 'Flute',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
result: 'Track updated:\ntrack_id: 1\ntrack_name: Lead\ninstrument: Flute',
|
||||
});
|
||||
expect(track.getInstrument()).toBe('flute');
|
||||
});
|
||||
|
||||
it('updates both track name and instrument', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const project = new KGProject('rename-and-instrument-project');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '1',
|
||||
new_track_name: 'Flute Lead',
|
||||
instrument: 'Flute',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
result: 'Track updated:\ntrack_id: 1\ntrack_name: Flute Lead\ninstrument: Flute',
|
||||
});
|
||||
expect(track.getName()).toBe('Flute Lead');
|
||||
expect(track.getInstrument()).toBe('flute');
|
||||
});
|
||||
|
||||
it('uses track_id when both track_id and track_name are provided', async () => {
|
||||
const leadTrack = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const bassTrack = new KGMidiTrack('Bass', 2, 'acoustic_bass');
|
||||
const project = new KGProject('track-id-precedence-project');
|
||||
project.setTracks([leadTrack, bassTrack]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '2',
|
||||
track_name: 'Lead',
|
||||
new_track_name: 'Bass 2',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(leadTrack.getName()).toBe('Lead');
|
||||
expect(bassTrack.getName()).toBe('Bass 2');
|
||||
});
|
||||
|
||||
it('rejects duplicate track names when track_id is omitted', async () => {
|
||||
const firstLead = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const secondLead = new KGMidiTrack('Lead', 2, 'flute');
|
||||
const project = new KGProject('duplicate-name-project');
|
||||
project.setTracks([firstLead, secondLead]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_name: 'Lead',
|
||||
new_track_name: 'Lead 2',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'Multiple MIDI tracks share the name "Lead". Provide track_id instead.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error when neither identifier is provided', async () => {
|
||||
const project = new KGProject('missing-id-project');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
new_track_name: 'Lead 2',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'Either track_id or track_name must be provided.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error when no update fields are provided', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const project = new KGProject('missing-fields-project');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '1',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'At least one of instrument or new_track_name must be provided.',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats empty string optional fields as not provided', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const project = new KGProject('empty-string-fields-project');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '1',
|
||||
new_track_name: '',
|
||||
instrument: '',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'At least one of instrument or new_track_name must be provided.',
|
||||
});
|
||||
expect(tool.buildConfirmationContent({
|
||||
track_id: '1',
|
||||
new_track_name: '',
|
||||
instrument: '',
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats null optional fields as not provided', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const project = new KGProject('null-fields-project');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '1',
|
||||
new_track_name: null,
|
||||
instrument: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'At least one of instrument or new_track_name must be provided.',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies a valid change when the other optional field is empty', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const project = new KGProject('mixed-empty-field-project');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '1',
|
||||
new_track_name: '',
|
||||
instrument: 'Flute',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
result: 'Track updated:\ntrack_id: 1\ntrack_name: Lead\ninstrument: Flute',
|
||||
});
|
||||
expect(track.getInstrument()).toBe('flute');
|
||||
expect(tool.buildConfirmationContent({
|
||||
track_id: '1',
|
||||
new_track_name: '',
|
||||
instrument: 'Flute',
|
||||
})).toBe('Allow updating track ID **1** to set instrument to **Flute**?');
|
||||
});
|
||||
|
||||
it('returns an error when provided values do not change the track', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const project = new KGProject('unchanged-values-project');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '1',
|
||||
new_track_name: 'Lead',
|
||||
instrument: 'Trumpet',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'No changes to apply to the target track.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error for an invalid instrument', async () => {
|
||||
const track = new KGMidiTrack('Lead', 1, 'trumpet');
|
||||
const project = new KGProject('invalid-instrument-project');
|
||||
project.setTracks([track]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '1',
|
||||
instrument: 'trumpet',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'Invalid instrument "trumpet". Use the exact English name from list_all_available_instruments.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error when the track does not exist', async () => {
|
||||
const project = new KGProject('missing-track-project');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '99',
|
||||
new_track_name: 'Lead 2',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'Track with ID "99" not found or is not a MIDI track.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error when the target is not a MIDI track', async () => {
|
||||
const audioTrack = new KGAudioTrack('Vocal', 1);
|
||||
const project = new KGProject('audio-track-project');
|
||||
project.setTracks([audioTrack]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new UpdateTrackTool();
|
||||
const result = await tool.execute({
|
||||
track_id: '1',
|
||||
new_track_name: 'Vocal 2',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
result: 'Track with ID "1" not found or is not a MIDI track.',
|
||||
});
|
||||
expect(tool.isReadOnlyTool()).toBe(false);
|
||||
expect(tool.isAvailableInEfficientMode()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { UpdateTrackCommand } from '../../core/commands/track/UpdateTrackCommand';
|
||||
import { KGMidiTrack, type InstrumentType } from '../../core/track/KGMidiTrack';
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import {
|
||||
getEnglishInstrumentName,
|
||||
getTrackDisplayName,
|
||||
resolveInstrumentKeyByEnglishName,
|
||||
resolveMidiTrackByExactName,
|
||||
resolveMidiTrackByIdOrName,
|
||||
} from './toolTargeting';
|
||||
|
||||
export class UpdateTrackTool extends BaseTool {
|
||||
readonly name = 'update_track';
|
||||
readonly description =
|
||||
'Update an existing MIDI track by track_id or track_name. Supports renaming the track and/or changing its instrument to an exact English instrument name from list_all_available_instruments.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
track_id: {
|
||||
type: 'string',
|
||||
description: 'Target MIDI track ID. Preferred when available.',
|
||||
required: false,
|
||||
},
|
||||
track_name: {
|
||||
type: 'string',
|
||||
description: 'Target MIDI track name. Used only when track_id is omitted.',
|
||||
required: false,
|
||||
},
|
||||
instrument: {
|
||||
type: 'string',
|
||||
description: 'Optional exact English instrument name from list_all_available_instruments.',
|
||||
required: false,
|
||||
},
|
||||
new_track_name: {
|
||||
type: 'string',
|
||||
description: 'Optional new name for the track.',
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
|
||||
override isReadOnlyTool(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override isAvailableInEfficientMode(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
|
||||
if (!args) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedInstrumentName = this.normalizeOptionalString(args.instrument);
|
||||
const normalizedNewTrackName = this.normalizeOptionalString(args.new_track_name);
|
||||
const targetLabel = typeof args.track_id === 'string'
|
||||
? `track ID **${args.track_id}**`
|
||||
: typeof args.track_name === 'string'
|
||||
? `track **${args.track_name}**`
|
||||
: null;
|
||||
if (!targetLabel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
if (normalizedNewTrackName !== undefined) {
|
||||
changes.push(`rename to **${normalizedNewTrackName}**`);
|
||||
}
|
||||
if (normalizedInstrumentName !== undefined) {
|
||||
changes.push(`set instrument to **${normalizedInstrumentName}**`);
|
||||
}
|
||||
if (changes.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `Allow updating ${targetLabel} to ${changes.join(' and ')}?`;
|
||||
}
|
||||
|
||||
override buildToolResultDisplayContent(args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
if (!args || !toolResult.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trackIdMatch = toolResult.result.match(/track_id:\s*(\d+)/);
|
||||
const trackNameMatch = toolResult.result.match(/track_name:\s*(.+)/);
|
||||
const instrumentMatch = toolResult.result.match(/instrument:\s*(.+)/);
|
||||
if (!trackIdMatch || !trackNameMatch || !instrumentMatch) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
'Track updated:',
|
||||
`- track_id: ${trackIdMatch[1]}`,
|
||||
`- track_name: ${trackNameMatch[1]}`,
|
||||
`- instrument: ${instrumentMatch[1]}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
this.validateParameters(params);
|
||||
|
||||
const trackId = params.track_id as string | undefined;
|
||||
const trackName = params.track_name as string | undefined;
|
||||
const instrumentName = this.normalizeOptionalString(params.instrument);
|
||||
const newTrackName = this.normalizeOptionalString(params.new_track_name);
|
||||
|
||||
if (!trackId && !trackName) {
|
||||
return this.createErrorResult('Either track_id or track_name must be provided.');
|
||||
}
|
||||
|
||||
if (instrumentName === undefined && newTrackName === undefined) {
|
||||
return this.createErrorResult('At least one of instrument or new_track_name must be provided.');
|
||||
}
|
||||
|
||||
if (!trackId && trackName) {
|
||||
const matchingTracks = resolveMidiTrackByExactName(trackName);
|
||||
if (matchingTracks.length > 1) {
|
||||
return this.createErrorResult(`Multiple MIDI tracks share the name "${trackName}". Provide track_id instead.`);
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedTrack = resolveMidiTrackByIdOrName(trackId, trackName);
|
||||
if (!resolvedTrack) {
|
||||
return this.createErrorResult(
|
||||
trackId
|
||||
? `Track with ID "${trackId}" not found or is not a MIDI track.`
|
||||
: `Track with name "${trackName}" not found or is not a MIDI track.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!(resolvedTrack instanceof KGMidiTrack)) {
|
||||
return this.createErrorResult(
|
||||
`Track "${getTrackDisplayName(resolvedTrack)}" is not a MIDI track.`,
|
||||
);
|
||||
}
|
||||
|
||||
let instrumentKey: InstrumentType | undefined;
|
||||
if (instrumentName !== undefined) {
|
||||
instrumentKey = resolveInstrumentKeyByEnglishName(instrumentName);
|
||||
if (!instrumentKey) {
|
||||
return this.createErrorResult(`Invalid instrument "${instrumentName}". Use the exact English name from list_all_available_instruments.`);
|
||||
}
|
||||
}
|
||||
|
||||
const trackNameChanged = newTrackName !== undefined && newTrackName !== resolvedTrack.getName();
|
||||
const instrumentChanged = instrumentKey !== undefined && instrumentKey !== resolvedTrack.getInstrument();
|
||||
|
||||
if (!trackNameChanged && !instrumentChanged) {
|
||||
return this.createErrorResult('No changes to apply to the target track.');
|
||||
}
|
||||
|
||||
const command = new UpdateTrackCommand(resolvedTrack.getId(), {
|
||||
...(trackNameChanged ? { name: newTrackName } : {}),
|
||||
...(instrumentChanged && instrumentKey !== undefined ? { instrument: instrumentKey } : {}),
|
||||
});
|
||||
await this.executeCommand(command);
|
||||
|
||||
return this.createSuccessResult([
|
||||
'Track updated:',
|
||||
`track_id: ${resolvedTrack.getId().toString()}`,
|
||||
`track_name: ${resolvedTrack.getName()}`,
|
||||
`instrument: ${getEnglishInstrumentName(resolvedTrack.getInstrument())}`,
|
||||
].join('\n'));
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to update track: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value === '' ? undefined : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WriteBpmTool } from './WriteBpmTool';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGTempoRegion } from '../../core/region/KGTempoRegion';
|
||||
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
|
||||
import { GlobalTrackType } from '../../core/global-track';
|
||||
|
||||
function mockCore(project: KGProject) {
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [],
|
||||
executeCommand: (command: { execute(): void }) => command.execute(),
|
||||
} as unknown as KGCore);
|
||||
}
|
||||
|
||||
function getTempoTrack(project: KGProject) {
|
||||
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||
expect(track).not.toBeNull();
|
||||
return track!;
|
||||
}
|
||||
|
||||
describe('WriteBpmTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('exposes the expected write-only availability and schema details', () => {
|
||||
const project = new KGProject('tool-definition-project', 8, 0, 120, { numerator: 4, denominator: 4 });
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteBpmTool();
|
||||
const definition = tool.getDefinition();
|
||||
|
||||
expect(tool.isReadOnlyTool()).toBe(false);
|
||||
expect(tool.isAvailableInEfficientMode()).toBe(false);
|
||||
expect(definition.function.name).toBe('write_bpm');
|
||||
expect(definition.function.description).toContain('Tempo track');
|
||||
expect(JSON.stringify(definition.function.parameters)).toContain('bpms');
|
||||
});
|
||||
|
||||
it('builds a confirmation summary for a full-song rewrite', () => {
|
||||
const project = new KGProject('confirmation-project', 8, 0, 120, { numerator: 4, denominator: 4 });
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteBpmTool();
|
||||
expect(tool.buildConfirmationContent({
|
||||
bpms: [
|
||||
{ bpm: 100 },
|
||||
{ bpm: 128, beat: 8 },
|
||||
],
|
||||
})).toBe('Allow rebuilding the global Tempo track with default tempo 100 BPM and 1 explicit tempo change from beat 8 to beat 8?');
|
||||
});
|
||||
|
||||
it('writes only a global/default BPM', async () => {
|
||||
const project = new KGProject('single-write-project', 8, 0, 120, { numerator: 4, denominator: 4 });
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteBpmTool();
|
||||
const result = await tool.execute({
|
||||
bpms: [{ bpm: 96 }],
|
||||
});
|
||||
|
||||
const track = getTempoTrack(project);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(project.getBpm()).toBe(96);
|
||||
expect(track.getRegions()).toHaveLength(0);
|
||||
expect(result.result).toContain('Project default BPM: 96 BPM');
|
||||
expect(tool.buildToolHistoryContent({}, result)).toBe(result.result);
|
||||
expect(tool.buildToolResultDisplayContent({}, result)).toBe(result.result);
|
||||
});
|
||||
|
||||
it('writes default BPM plus explicit entries and keeps the track gapless', async () => {
|
||||
const project = new KGProject('explicit-write-project', 8, 0, 120, { numerator: 4, denominator: 4 });
|
||||
const track = getTempoTrack(project);
|
||||
track.setRegions([
|
||||
new KGTempoRegion('existing-1', track.getId(), track.getTrackIndex(), 120, 0, 2, 4),
|
||||
new KGTempoRegion('existing-2', track.getId(), track.getTrackIndex(), 140, 2, 6, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteBpmTool();
|
||||
const result = await tool.execute({
|
||||
bpms: [
|
||||
{ bpm: 100 },
|
||||
{ bpm: 128, beat: 8 },
|
||||
{ bpm: 144, beat: 16 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(project.getBpm()).toBe(100);
|
||||
expect((track.getRegions() as KGTempoRegion[]).map(region => ({
|
||||
bpm: region.getBpm(),
|
||||
startBar: region.getStartBar(),
|
||||
lengthBars: region.getLengthBars(),
|
||||
}))).toEqual([
|
||||
{ bpm: 100, startBar: 0, lengthBars: 2 },
|
||||
{ bpm: 128, startBar: 2, lengthBars: 2 },
|
||||
{ bpm: 144, startBar: 4, lengthBars: 4 },
|
||||
]);
|
||||
expect(result.result).toContain('128 BPM from beat 8 (bar 3)');
|
||||
});
|
||||
|
||||
it('rejects an empty BPM list', async () => {
|
||||
const project = new KGProject('empty-project', 8, 0, 120, { numerator: 4, denominator: 4 });
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteBpmTool();
|
||||
const result = await tool.execute({ bpms: [] });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('must contain at least one BPM entry');
|
||||
});
|
||||
|
||||
it('rejects invalid BPM values', async () => {
|
||||
const project = new KGProject('bad-bpm-project', 8, 0, 120, { numerator: 4, denominator: 4 });
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteBpmTool();
|
||||
const zeroResult = await tool.execute({ bpms: [{ bpm: 0 }] });
|
||||
const nanResult = await tool.execute({ bpms: [{ bpm: Number.NaN }] });
|
||||
|
||||
expect(zeroResult.success).toBe(false);
|
||||
expect(zeroResult.result).toContain('invalid "bpm"');
|
||||
expect(nanResult.success).toBe(false);
|
||||
expect(nanResult.result).toContain('invalid "bpm"');
|
||||
});
|
||||
|
||||
it('rejects invalid beats', async () => {
|
||||
const project = new KGProject('bad-beat-project', 8, 0, 120, { numerator: 4, denominator: 4 });
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteBpmTool();
|
||||
const badBeatResult = await tool.execute({ bpms: [{ bpm: 120, beat: -1 }] });
|
||||
const outOfRangeResult = await tool.execute({ bpms: [{ bpm: 120, beat: 32 }] });
|
||||
|
||||
expect(badBeatResult.success).toBe(false);
|
||||
expect(badBeatResult.result).toContain('invalid "beat"');
|
||||
expect(outOfRangeResult.success).toBe(false);
|
||||
expect(outOfRangeResult.result).toContain('within the song range');
|
||||
});
|
||||
|
||||
it('rejects duplicate global/default entries', async () => {
|
||||
const project = new KGProject('duplicate-default-project', 8, 0, 120, { numerator: 4, denominator: 4 });
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteBpmTool();
|
||||
const result = await tool.execute({
|
||||
bpms: [
|
||||
{ bpm: 100 },
|
||||
{ bpm: 120, beat: '' as const },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('Only one global/default BPM entry');
|
||||
});
|
||||
|
||||
it('rejects entries that collapse into the same bar after normalization', async () => {
|
||||
const project = new KGProject('same-bar-project', 8, 0, 120, { numerator: 4, denominator: 4 });
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteBpmTool();
|
||||
const result = await tool.execute({
|
||||
bpms: [
|
||||
{ bpm: 100 },
|
||||
{ bpm: 128, beat: 4 },
|
||||
{ bpm: 132, beat: 7 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('after bar alignment');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import {
|
||||
WriteTempoTrackCommand,
|
||||
type WriteTempoEntry,
|
||||
} from '../../core/commands/global-region/WriteTempoTrackCommand';
|
||||
|
||||
interface RequestedBpmEntry {
|
||||
bpm: number;
|
||||
beat?: number | null | '';
|
||||
}
|
||||
|
||||
interface NormalizedExplicitEntry extends WriteTempoEntry {
|
||||
inputBeat: number;
|
||||
normalizedBar: number;
|
||||
}
|
||||
|
||||
interface NormalizedPayload {
|
||||
baseBpm: number;
|
||||
explicitEntries: NormalizedExplicitEntry[];
|
||||
}
|
||||
|
||||
export class WriteBpmTool extends BaseTool {
|
||||
readonly name = 'write_bpm';
|
||||
readonly description = 'Write BPM changes to the global Tempo track. This tool updates the project default BPM and rebuilds the Tempo track as a gapless full-song tempo plan with bar-aligned boundaries.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
bpms: {
|
||||
type: 'array',
|
||||
description: 'The complete BPM plan to write. Provide one optional beat-less item for the project default BPM, plus any explicit beat-based tempo changes.',
|
||||
required: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
description: 'One BPM entry. Omit "beat", set it to null, or set it to an empty string to provide the global/default BPM.',
|
||||
properties: {
|
||||
bpm: {
|
||||
type: 'number',
|
||||
description: 'Required BPM value. Must be a finite number greater than 0.',
|
||||
required: true,
|
||||
},
|
||||
beat: {
|
||||
type: 'number',
|
||||
description: 'Optional absolute beat on the project timeline. When omitted, null, or empty, this entry becomes the project default BPM.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
override isReadOnlyTool(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override isAvailableInEfficientMode(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override buildToolResultDisplayContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
return toolResult.result;
|
||||
}
|
||||
|
||||
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
return toolResult.result;
|
||||
}
|
||||
|
||||
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
|
||||
if (!args) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const normalized = this.normalizePayload(args.bpms as RequestedBpmEntry[]);
|
||||
const explicitEntries = normalized.explicitEntries;
|
||||
if (explicitEntries.length === 0) {
|
||||
return `Allow rebuilding the global Tempo track using project default tempo ${normalized.baseBpm} BPM with no explicit tempo regions?`;
|
||||
}
|
||||
|
||||
const firstBeat = explicitEntries[0].inputBeat;
|
||||
const lastBeat = explicitEntries[explicitEntries.length - 1].inputBeat;
|
||||
return `Allow rebuilding the global Tempo track with default tempo ${normalized.baseBpm} BPM and ${explicitEntries.length} explicit tempo ${explicitEntries.length === 1 ? 'change' : 'changes'} from beat ${firstBeat} to beat ${lastBeat}?`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
if (!Array.isArray(params.bpms)) {
|
||||
throw new Error('Parameter "bpms" must be an array.');
|
||||
}
|
||||
|
||||
const normalized = this.normalizePayload(params.bpms as RequestedBpmEntry[]);
|
||||
const command = new WriteTempoTrackCommand(
|
||||
normalized.baseBpm,
|
||||
normalized.explicitEntries.map(entry => ({
|
||||
startBeat: entry.startBeat,
|
||||
bpm: entry.bpm,
|
||||
})),
|
||||
);
|
||||
await this.executeCommand(command);
|
||||
|
||||
const explicitDetails = normalized.explicitEntries.length === 0
|
||||
? 'No explicit tempo regions were written; the Tempo track now falls back entirely to the project default BPM.'
|
||||
: normalized.explicitEntries
|
||||
.map(entry => `${entry.bpm} BPM from beat ${entry.startBeat} (bar ${entry.normalizedBar + 1})`)
|
||||
.join(', ');
|
||||
|
||||
return this.createSuccessResult(
|
||||
`Successfully rebuilt the global Tempo track. Project default BPM: ${normalized.baseBpm} BPM. ${explicitDetails}`,
|
||||
);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to write BPM: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizePayload(entries: RequestedBpmEntry[]): NormalizedPayload {
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
throw new Error('Parameter "bpms" must contain at least one BPM entry.');
|
||||
}
|
||||
|
||||
const project = this.getCurrentProject();
|
||||
const beatsPerBar = project.getTimeSignature().numerator;
|
||||
const songEndBeat = project.getMaxBars() * beatsPerBar;
|
||||
|
||||
let baseBpm = project.getBpm();
|
||||
let sawDefaultEntry = false;
|
||||
const explicitEntries: NormalizedExplicitEntry[] = [];
|
||||
|
||||
entries.forEach((entry, index) => {
|
||||
const bpm = this.validateBpm(entry.bpm, index);
|
||||
const beat = entry.beat;
|
||||
|
||||
if (beat === undefined || beat === null || beat === '') {
|
||||
if (sawDefaultEntry) {
|
||||
throw new Error('Only one global/default BPM entry may omit the "beat" field.');
|
||||
}
|
||||
baseBpm = bpm;
|
||||
sawDefaultEntry = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(beat)) {
|
||||
throw new Error(`BPM entry ${index + 1} has invalid "beat": ${String(beat)}. Expected a finite number >= 0.`);
|
||||
}
|
||||
if (beat < 0) {
|
||||
throw new Error(`BPM entry ${index + 1} has invalid "beat": ${beat}. Expected a value >= 0.`);
|
||||
}
|
||||
if (beat >= songEndBeat) {
|
||||
throw new Error(`BPM entry ${index + 1} has invalid "beat": ${beat}. It must be within the song range.`);
|
||||
}
|
||||
|
||||
explicitEntries.push({
|
||||
bpm,
|
||||
startBeat: beat,
|
||||
inputBeat: beat,
|
||||
normalizedBar: Math.floor(beat / beatsPerBar),
|
||||
});
|
||||
});
|
||||
|
||||
explicitEntries.sort((left, right) => left.startBeat - right.startBeat);
|
||||
|
||||
for (let index = 1; index < explicitEntries.length; index += 1) {
|
||||
const previous = explicitEntries[index - 1];
|
||||
const current = explicitEntries[index];
|
||||
if (current.normalizedBar <= previous.normalizedBar) {
|
||||
throw new Error(
|
||||
`BPM entry ${index + 1} overlaps with or collapses into entry ${index} after bar alignment. Entry ${index} normalizes to bar ${previous.normalizedBar + 1}, and entry ${index + 1} normalizes to bar ${current.normalizedBar + 1}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
baseBpm,
|
||||
explicitEntries,
|
||||
};
|
||||
}
|
||||
|
||||
private validateBpm(rawValue: number, index: number): number {
|
||||
if (!Number.isFinite(rawValue) || rawValue <= 0) {
|
||||
throw new Error(`BPM entry ${index + 1} has invalid "bpm": ${String(rawValue)}. Expected a finite number greater than 0.`);
|
||||
}
|
||||
|
||||
return rawValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WriteChordProgressionTool } from './WriteChordProgressionTool';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGChordRegion } from '../../core/region/KGChordRegion';
|
||||
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
|
||||
import { GlobalTrackType } from '../../core/global-track';
|
||||
|
||||
function mockCore(project: KGProject) {
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [],
|
||||
executeCommand: (command: { execute(): void }) => command.execute(),
|
||||
} as unknown as KGCore);
|
||||
}
|
||||
|
||||
function getChordTrack(project: KGProject) {
|
||||
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
|
||||
expect(chordTrack).not.toBeNull();
|
||||
return chordTrack!;
|
||||
}
|
||||
|
||||
describe('WriteChordProgressionTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('exposes the expected write-only availability and schema details', () => {
|
||||
const project = new KGProject('tool-definition-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteChordProgressionTool();
|
||||
const definition = tool.getDefinition();
|
||||
|
||||
expect(tool.isReadOnlyTool()).toBe(false);
|
||||
expect(tool.isAvailableInEfficientMode()).toBe(false);
|
||||
expect(definition.function.name).toBe('write_chord_progression');
|
||||
expect(definition.function.description).toContain('reference only');
|
||||
expect(JSON.stringify(definition.function.parameters)).toContain('Bm7b5');
|
||||
});
|
||||
|
||||
it('writes a single chord into an empty chord track and canonicalizes the symbol', async () => {
|
||||
const project = new KGProject('single-write-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteChordProgressionTool();
|
||||
const result = await tool.execute({
|
||||
chords: [{ chord: ' bm7b5 ', start: 4, length: 4 }],
|
||||
});
|
||||
|
||||
const chordTrack = getChordTrack(project);
|
||||
expect(result.success).toBe(true);
|
||||
expect((chordTrack.getRegions()[0] as KGChordRegion).getSymbol()).toBe('Bm7b5');
|
||||
expect(tool.buildToolHistoryContent({}, result)).toBe(result.result);
|
||||
expect(tool.buildToolResultDisplayContent({ chords: [{ chord: 'Bm7b5', start: 4, length: 4 }] }, result))
|
||||
.toBe('Updated 1 chord reference on the global Chord Track across bar 2.');
|
||||
});
|
||||
|
||||
it('builds a confirmation summary for the affected bar span', () => {
|
||||
const project = new KGProject('confirmation-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteChordProgressionTool();
|
||||
expect(tool.buildConfirmationContent({
|
||||
chords: [
|
||||
{ chord: 'C', start: 0, length: 4 },
|
||||
{ chord: 'Dm', start: 4, length: 4 },
|
||||
],
|
||||
})).toBe('Allow updating 2 chord references on the global Chord Track across bars 1 to 2?');
|
||||
});
|
||||
|
||||
it('rejects an empty chord list', async () => {
|
||||
const project = new KGProject('empty-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteChordProgressionTool();
|
||||
const result = await tool.execute({ chords: [] });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('must contain at least one chord entry');
|
||||
});
|
||||
|
||||
it('rejects unparsable chord symbols with a field-specific error', async () => {
|
||||
const project = new KGProject('bad-chord-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteChordProgressionTool();
|
||||
const result = await tool.execute({ chords: [{ chord: 'not-a-chord', start: 0, length: 4 }] });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('Chord entry 1 has invalid "chord"');
|
||||
});
|
||||
|
||||
it('rejects negative start and non-positive length values', async () => {
|
||||
const project = new KGProject('bad-number-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteChordProgressionTool();
|
||||
const badStartResult = await tool.execute({ chords: [{ chord: 'C', start: -1, length: 4 }] });
|
||||
const badLengthResult = await tool.execute({ chords: [{ chord: 'C', start: 0, length: 0 }] });
|
||||
|
||||
expect(badStartResult.success).toBe(false);
|
||||
expect(badStartResult.result).toContain('invalid "start"');
|
||||
expect(badLengthResult.success).toBe(false);
|
||||
expect(badLengthResult.result).toContain('invalid "length"');
|
||||
});
|
||||
|
||||
it('rejects overlapping requested chord entries', async () => {
|
||||
const project = new KGProject('overlap-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteChordProgressionTool();
|
||||
const result = await tool.execute({
|
||||
chords: [
|
||||
{ chord: 'C', start: 0, length: 4 },
|
||||
{ chord: 'Dm', start: 3, length: 4 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('overlaps with chord entry 1');
|
||||
});
|
||||
|
||||
it('preserves untouched gaps and trims existing overlapping chord regions', async () => {
|
||||
const project = new KGProject('preserve-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const chordTrack = getChordTrack(project);
|
||||
chordTrack.setRegions([
|
||||
new KGChordRegion('chord-1', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 8),
|
||||
new KGChordRegion('chord-2', chordTrack.getId(), chordTrack.getTrackIndex(), 'F', 8, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteChordProgressionTool();
|
||||
const result = await tool.execute({
|
||||
chords: [
|
||||
{ chord: 'C', start: 3, length: 2 },
|
||||
{ chord: 'G', start: 10, length: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect((chordTrack.getRegions() as KGChordRegion[]).map(region => ({
|
||||
symbol: region.getSymbol(),
|
||||
start: region.getStartFromBeat(),
|
||||
length: region.getLength(),
|
||||
}))).toEqual([
|
||||
{ symbol: 'Am', start: 0, length: 3 },
|
||||
{ symbol: 'C', start: 3, length: 2 },
|
||||
{ symbol: 'Am', start: 5, length: 3 },
|
||||
{ symbol: 'F', start: 8, length: 2 },
|
||||
{ symbol: 'G', start: 10, length: 2 },
|
||||
]);
|
||||
expect(result.result).toContain('harmonic reference only');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import {
|
||||
WriteChordProgressionCommand,
|
||||
type WriteChordProgressionEntry,
|
||||
} from '../../core/commands/global-region/WriteChordProgressionCommand';
|
||||
import { parseChordSymbol } from '../../util/chordUtil';
|
||||
|
||||
interface RequestedChordEntry {
|
||||
chord: string;
|
||||
start: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
interface ValidatedChordEntry extends WriteChordProgressionEntry {
|
||||
chord: string;
|
||||
}
|
||||
|
||||
interface ChordWriteSummaryData {
|
||||
chordCount: number;
|
||||
startBar: number;
|
||||
endBar: number;
|
||||
}
|
||||
|
||||
export class WriteChordProgressionTool extends BaseTool {
|
||||
readonly name = 'write_chord_progression';
|
||||
readonly description = 'Write chord-reference regions to the global chord track using absolute beat positions on the project timeline. The global chord track is for harmonic reference only and does not affect playback by itself. If the user wants audible chord playback, create notes on actual MIDI tracks with add_notes instead. Use chord symbols matching the chord input popup format, such as C, Dm, or Bm7b5.';
|
||||
|
||||
override isReadOnlyTool(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override isAvailableInEfficientMode(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
chords: {
|
||||
type: 'array',
|
||||
description: 'Chord-reference regions to write to the global chord track. Each entry uses an absolute beat start on the project timeline. Use chord symbols matching the chord input popup format, for example C, Dm, or Bm7b5.',
|
||||
required: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
description: 'A single chord-reference region',
|
||||
properties: {
|
||||
chord: {
|
||||
type: 'string',
|
||||
description: 'Chord symbol in the app-accepted chord format. Examples: "C", "Dm", "Bm7b5".',
|
||||
required: true,
|
||||
},
|
||||
start: {
|
||||
type: 'number',
|
||||
description: 'Start beat on the absolute project timeline. This is not relative to a clip or region.',
|
||||
required: true,
|
||||
},
|
||||
length: {
|
||||
type: 'number',
|
||||
description: 'Chord duration in beats. Must be greater than 0.',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
override buildToolResultDisplayContent(args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
if (!args || !toolResult.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const summary = this.buildSummaryData(args);
|
||||
if (!summary) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `Updated ${summary.chordCount} chord ${summary.chordCount === 1 ? 'reference' : 'references'} on the global Chord Track across ${summary.startBar === summary.endBar ? `bar ${summary.startBar}` : `bars ${summary.startBar} to ${summary.endBar}`}.`;
|
||||
}
|
||||
|
||||
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
return toolResult.result;
|
||||
}
|
||||
|
||||
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
|
||||
if (!args) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const summary = this.buildSummaryData(args);
|
||||
if (!summary) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `Allow updating ${summary.chordCount} chord ${summary.chordCount === 1 ? 'reference' : 'references'} on the global Chord Track across ${summary.startBar === summary.endBar ? `bar ${summary.startBar}` : `bars ${summary.startBar} to ${summary.endBar}`}?`;
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
this.validateParameters(params);
|
||||
|
||||
const validatedChords = this.validateAndNormalizeChords(params.chords as RequestedChordEntry[]);
|
||||
const command = new WriteChordProgressionCommand(validatedChords.map(chord => ({
|
||||
startBeat: chord.startBeat,
|
||||
length: chord.length,
|
||||
symbol: chord.chord,
|
||||
})));
|
||||
await this.executeCommand(command);
|
||||
|
||||
const details = validatedChords
|
||||
.map(chord => `"${chord.chord}" from beat ${chord.startBeat} to beat ${chord.startBeat + chord.length}`)
|
||||
.join(', ');
|
||||
|
||||
return this.createSuccessResult(
|
||||
`Successfully wrote ${validatedChords.length} chord ${validatedChords.length === 1 ? 'reference' : 'references'} to the global chord track: ${details}. These chord regions are for harmonic reference only and do not change playback by themselves. If audible playback is needed, create notes on actual MIDI tracks with add_notes.`,
|
||||
);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to write chord progression: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private buildSummaryData(args: Record<string, unknown>): ChordWriteSummaryData | null {
|
||||
const typedArgs = args as { chords?: Array<{ start: number; length: number }> };
|
||||
if (!Array.isArray(typedArgs.chords) || typedArgs.chords.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const beatsPerBar = this.getCurrentProject().getTimeSignature().numerator;
|
||||
const startBeat = Math.min(...typedArgs.chords.map(chord => chord.start));
|
||||
const endBeat = Math.max(...typedArgs.chords.map(chord => chord.start + chord.length));
|
||||
|
||||
return {
|
||||
chordCount: typedArgs.chords.length,
|
||||
startBar: Math.floor(startBeat / beatsPerBar) + 1,
|
||||
endBar: Math.max(1, Math.ceil(endBeat / beatsPerBar)),
|
||||
};
|
||||
}
|
||||
|
||||
private validateAndNormalizeChords(chords: RequestedChordEntry[]): ValidatedChordEntry[] {
|
||||
if (chords.length === 0) {
|
||||
throw new Error('Parameter "chords" must contain at least one chord entry.');
|
||||
}
|
||||
|
||||
const validated = chords.map((chord, index) => this.validateChordEntry(chord, index));
|
||||
validated.sort((left, right) => left.startBeat - right.startBeat);
|
||||
|
||||
for (let index = 1; index < validated.length; index += 1) {
|
||||
const previous = validated[index - 1];
|
||||
const current = validated[index];
|
||||
if (current.startBeat < previous.startBeat + previous.length) {
|
||||
throw new Error(
|
||||
`Chord entry ${index + 1} overlaps with chord entry ${index}. Entry ${index} ends at beat ${previous.startBeat + previous.length}, but entry ${index + 1} starts at beat ${current.startBeat}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
private validateChordEntry(chord: RequestedChordEntry, index: number): ValidatedChordEntry {
|
||||
if (!Number.isFinite(chord.start)) {
|
||||
throw new Error(`Chord entry ${index + 1} has invalid "start": ${String(chord.start)}. Expected a finite number >= 0.`);
|
||||
}
|
||||
if (chord.start < 0) {
|
||||
throw new Error(`Chord entry ${index + 1} has invalid "start": ${chord.start}. Expected a value >= 0.`);
|
||||
}
|
||||
if (!Number.isFinite(chord.length)) {
|
||||
throw new Error(`Chord entry ${index + 1} has invalid "length": ${String(chord.length)}. Expected a finite number > 0.`);
|
||||
}
|
||||
if (chord.length <= 0) {
|
||||
throw new Error(`Chord entry ${index + 1} has invalid "length": ${chord.length}. Expected a value > 0.`);
|
||||
}
|
||||
|
||||
const trimmedChord = chord.chord?.trim();
|
||||
if (!trimmedChord) {
|
||||
throw new Error(`Chord entry ${index + 1} has invalid "chord": expected a non-empty chord symbol.`);
|
||||
}
|
||||
|
||||
const parsed = parseChordSymbol(trimmedChord);
|
||||
if (!parsed) {
|
||||
throw new Error(`Chord entry ${index + 1} has invalid "chord": "${trimmedChord}". Use a chord symbol the app can parse, such as C, Dm, or Bm7b5.`);
|
||||
}
|
||||
|
||||
return {
|
||||
chord: parsed.symbol,
|
||||
symbol: parsed.symbol,
|
||||
startBeat: chord.start,
|
||||
length: chord.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WriteKeySignatureTool } from './WriteKeySignatureTool';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGKeySignatureRegion } from '../../core/region/KGKeySignatureRegion';
|
||||
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
|
||||
import { GlobalTrackType } from '../../core/global-track';
|
||||
|
||||
function mockCore(project: KGProject) {
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [],
|
||||
executeCommand: (command: { execute(): void }) => command.execute(),
|
||||
} as unknown as KGCore);
|
||||
}
|
||||
|
||||
function getSignatureTrack(project: KGProject) {
|
||||
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
|
||||
expect(track).not.toBeNull();
|
||||
return track!;
|
||||
}
|
||||
|
||||
describe('WriteKeySignatureTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('exposes the expected write-only availability and schema details', () => {
|
||||
const project = new KGProject('tool-definition-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteKeySignatureTool();
|
||||
const definition = tool.getDefinition();
|
||||
|
||||
expect(tool.isReadOnlyTool()).toBe(false);
|
||||
expect(tool.isAvailableInEfficientMode()).toBe(false);
|
||||
expect(definition.function.name).toBe('write_key_signature');
|
||||
expect(definition.function.description).toContain('C major');
|
||||
expect(JSON.stringify(definition.function.parameters)).toContain('F# minor');
|
||||
expect(JSON.stringify(definition.function.parameters)).toContain('Bb major');
|
||||
});
|
||||
|
||||
it('builds a confirmation summary for a full-song rewrite', () => {
|
||||
const project = new KGProject('confirmation-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteKeySignatureTool();
|
||||
expect(tool.buildConfirmationContent({
|
||||
key_signatures: [
|
||||
{ key_signature: 'C major' },
|
||||
{ key_signature: 'G major', beat: 8 },
|
||||
],
|
||||
})).toBe('Allow rebuilding the global Signature track with 2 key signatures from beat 8 to beat 8?');
|
||||
});
|
||||
|
||||
it('writes a single global key signature across the full song', async () => {
|
||||
const project = new KGProject('single-write-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteKeySignatureTool();
|
||||
const result = await tool.execute({
|
||||
key_signatures: [{ key_signature: 'E minor' }],
|
||||
});
|
||||
|
||||
const track = getSignatureTrack(project);
|
||||
const regions = track.getRegions() as KGKeySignatureRegion[];
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(regions).toHaveLength(1);
|
||||
expect(regions[0].getKeySignature()).toBe('E minor');
|
||||
expect(regions[0].getStartBar()).toBe(0);
|
||||
expect(regions[0].getLengthBars()).toBe(8);
|
||||
expect(tool.buildToolHistoryContent({}, result)).toBe(result.result);
|
||||
expect(tool.buildToolResultDisplayContent({}, result)).toBe(result.result);
|
||||
});
|
||||
|
||||
it('rewrites the signature track from explicit beat entries and keeps it gapless', async () => {
|
||||
const project = new KGProject('explicit-write-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const track = getSignatureTrack(project);
|
||||
track.setRegions([
|
||||
new KGKeySignatureRegion('existing-1', track.getId(), track.getTrackIndex(), 'D major', 0, 2, 4),
|
||||
new KGKeySignatureRegion('existing-2', track.getId(), track.getTrackIndex(), 'A major', 2, 6, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteKeySignatureTool();
|
||||
const result = await tool.execute({
|
||||
key_signatures: [
|
||||
{ key_signature: 'G major' },
|
||||
{ key_signature: 'D major', beat: 8 },
|
||||
{ key_signature: 'A major', beat: 16 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect((track.getRegions() as KGKeySignatureRegion[]).map(region => ({
|
||||
keySignature: region.getKeySignature(),
|
||||
startBar: region.getStartBar(),
|
||||
lengthBars: region.getLengthBars(),
|
||||
}))).toEqual([
|
||||
{ keySignature: 'G major', startBar: 0, lengthBars: 2 },
|
||||
{ keySignature: 'D major', startBar: 2, lengthBars: 2 },
|
||||
{ keySignature: 'A major', startBar: 4, lengthBars: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects an empty key-signature list', async () => {
|
||||
const project = new KGProject('empty-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteKeySignatureTool();
|
||||
const result = await tool.execute({ key_signatures: [] });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('must contain at least one key-signature entry');
|
||||
});
|
||||
|
||||
it('rejects invalid key-signature strings', async () => {
|
||||
const project = new KGProject('bad-key-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteKeySignatureTool();
|
||||
const result = await tool.execute({ key_signatures: [{ key_signature: 'not-a-key' }] });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('invalid "key_signature"');
|
||||
});
|
||||
|
||||
it('rejects invalid beats', async () => {
|
||||
const project = new KGProject('bad-beat-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteKeySignatureTool();
|
||||
const badBeatResult = await tool.execute({ key_signatures: [{ key_signature: 'C major', beat: -1 }] });
|
||||
const outOfRangeResult = await tool.execute({ key_signatures: [{ key_signature: 'C major', beat: 32 }] });
|
||||
|
||||
expect(badBeatResult.success).toBe(false);
|
||||
expect(badBeatResult.result).toContain('invalid "beat"');
|
||||
expect(outOfRangeResult.success).toBe(false);
|
||||
expect(outOfRangeResult.result).toContain('within the song range');
|
||||
});
|
||||
|
||||
it('rejects duplicate global/default entries', async () => {
|
||||
const project = new KGProject('duplicate-default-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteKeySignatureTool();
|
||||
const result = await tool.execute({
|
||||
key_signatures: [
|
||||
{ key_signature: 'C major' },
|
||||
{ key_signature: 'G major', beat: '' as const },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('Only one global/default key signature entry');
|
||||
});
|
||||
|
||||
it('rejects entries that collapse into the same bar after normalization', async () => {
|
||||
const project = new KGProject('same-bar-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteKeySignatureTool();
|
||||
const result = await tool.execute({
|
||||
key_signatures: [
|
||||
{ key_signature: 'C major' },
|
||||
{ key_signature: 'G major', beat: 4 },
|
||||
{ key_signature: 'D major', beat: 7 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('after bar alignment');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import { KEY_SIGNATURE_MAP } from '../../constants/coreConstants';
|
||||
import type { KeySignature } from '../../core/KGProject';
|
||||
import {
|
||||
WriteKeySignatureTrackCommand,
|
||||
type WriteKeySignatureEntry,
|
||||
} from '../../core/commands/global-region/WriteKeySignatureTrackCommand';
|
||||
|
||||
interface RequestedKeySignatureEntry {
|
||||
key_signature: string;
|
||||
beat?: number | null | '';
|
||||
}
|
||||
|
||||
interface NormalizedExplicitEntry extends WriteKeySignatureEntry {
|
||||
inputBeat: number;
|
||||
}
|
||||
|
||||
interface NormalizedPayload {
|
||||
baseKeySignature: KeySignature;
|
||||
explicitEntries: NormalizedExplicitEntry[];
|
||||
}
|
||||
|
||||
export class WriteKeySignatureTool extends BaseTool {
|
||||
readonly name = 'write_key_signature';
|
||||
readonly description = 'Write key-signature changes to the global Signature track using the same canonical key-signature format as the key-signature picker. Use exact picker values such as "C major", "F# minor", or "Bb major". This fully rebuilds the global Signature track as a gapless song-wide key plan.';
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
key_signatures: {
|
||||
type: 'array',
|
||||
description: 'The complete key-signature plan to write. Use exact key-signature picker values such as "C major", "F# minor", or "Bb major".',
|
||||
required: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
description: 'One key-signature entry. Omit "beat", set it to null, or set it to an empty string to provide the global/default key signature.',
|
||||
properties: {
|
||||
key_signature: {
|
||||
type: 'string',
|
||||
description: 'Required canonical key signature exactly matching the key-signature picker. Examples: "C major", "F# minor", "Bb major".',
|
||||
required: true,
|
||||
},
|
||||
beat: {
|
||||
type: 'number',
|
||||
description: 'Optional absolute beat on the project timeline. When omitted, null, or empty, this entry becomes the global/default key signature.',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
override isReadOnlyTool(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override isAvailableInEfficientMode(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override buildToolResultDisplayContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
return toolResult.result;
|
||||
}
|
||||
|
||||
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
return toolResult.result;
|
||||
}
|
||||
|
||||
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
|
||||
if (!args) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const normalized = this.normalizePayload(args.key_signatures as RequestedKeySignatureEntry[]);
|
||||
const explicitEntries = normalized.explicitEntries;
|
||||
const writeCount = explicitEntries.length + 1;
|
||||
|
||||
if (explicitEntries.length === 0) {
|
||||
return 'Allow rebuilding the global Signature track with 1 key signature across the full song?';
|
||||
}
|
||||
|
||||
const firstBeat = explicitEntries[0].inputBeat;
|
||||
const lastBeat = explicitEntries[explicitEntries.length - 1].inputBeat;
|
||||
return `Allow rebuilding the global Signature track with ${writeCount} key signatures from beat ${firstBeat} to beat ${lastBeat}?`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
if (!Array.isArray(params.key_signatures)) {
|
||||
throw new Error('Parameter "key_signatures" must be an array.');
|
||||
}
|
||||
const normalized = this.normalizePayload(params.key_signatures as RequestedKeySignatureEntry[]);
|
||||
|
||||
const command = new WriteKeySignatureTrackCommand(
|
||||
normalized.baseKeySignature,
|
||||
normalized.explicitEntries.map(entry => ({
|
||||
startBeat: entry.startBeat,
|
||||
keySignature: entry.keySignature,
|
||||
})),
|
||||
);
|
||||
await this.executeCommand(command);
|
||||
|
||||
const details = normalized.explicitEntries.length === 0
|
||||
? `base key signature "${normalized.baseKeySignature}" across the full song`
|
||||
: [
|
||||
`base key signature "${normalized.baseKeySignature}"`,
|
||||
...normalized.explicitEntries.map(entry => `"${entry.keySignature}" from beat ${entry.startBeat}`),
|
||||
].join(', ');
|
||||
|
||||
return this.createSuccessResult(
|
||||
`Successfully rebuilt the global Signature track as a gapless full-song key plan using ${details}. All boundaries were normalized to bar starts.`,
|
||||
);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to write key signature: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizePayload(entries: RequestedKeySignatureEntry[]): NormalizedPayload {
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
throw new Error('Parameter "key_signatures" must contain at least one key-signature entry.');
|
||||
}
|
||||
|
||||
const project = this.getCurrentProject();
|
||||
const beatsPerBar = project.getTimeSignature().numerator;
|
||||
const songEndBeat = project.getMaxBars() * beatsPerBar;
|
||||
|
||||
let baseKeySignature: KeySignature = project.getKeySignature();
|
||||
let sawDefaultEntry = false;
|
||||
const explicitEntries: NormalizedExplicitEntry[] = [];
|
||||
|
||||
entries.forEach((entry, index) => {
|
||||
const keySignature = this.validateKeySignature(entry.key_signature, index);
|
||||
const beat = entry.beat;
|
||||
if (beat === undefined || beat === null || beat === '') {
|
||||
if (sawDefaultEntry) {
|
||||
throw new Error('Only one global/default key signature entry may omit the "beat" field.');
|
||||
}
|
||||
baseKeySignature = keySignature;
|
||||
sawDefaultEntry = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(beat)) {
|
||||
throw new Error(`Key-signature entry ${index + 1} has invalid "beat": ${String(beat)}. Expected a finite number >= 0.`);
|
||||
}
|
||||
if (beat < 0) {
|
||||
throw new Error(`Key-signature entry ${index + 1} has invalid "beat": ${beat}. Expected a value >= 0.`);
|
||||
}
|
||||
if (beat >= songEndBeat) {
|
||||
throw new Error(`Key-signature entry ${index + 1} has invalid "beat": ${beat}. It must be within the song range.`);
|
||||
}
|
||||
|
||||
explicitEntries.push({
|
||||
keySignature,
|
||||
startBeat: beat,
|
||||
inputBeat: beat,
|
||||
});
|
||||
});
|
||||
|
||||
explicitEntries.sort((left, right) => left.startBeat - right.startBeat);
|
||||
|
||||
for (let index = 1; index < explicitEntries.length; index += 1) {
|
||||
const previous = explicitEntries[index - 1];
|
||||
const current = explicitEntries[index];
|
||||
const previousBar = Math.floor(previous.startBeat / beatsPerBar);
|
||||
const currentBar = Math.floor(current.startBeat / beatsPerBar);
|
||||
if (currentBar <= previousBar) {
|
||||
throw new Error(
|
||||
`Key-signature entry ${index + 1} overlaps with or collapses into entry ${index} after bar alignment. Entry ${index} normalizes to bar ${previousBar + 1}, and entry ${index + 1} normalizes to bar ${currentBar + 1}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
baseKeySignature,
|
||||
explicitEntries,
|
||||
};
|
||||
}
|
||||
|
||||
private validateKeySignature(rawValue: string, index: number): KeySignature {
|
||||
const trimmed = rawValue?.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`Key-signature entry ${index + 1} has invalid "key_signature": expected a non-empty string.`);
|
||||
}
|
||||
|
||||
if (!(trimmed in KEY_SIGNATURE_MAP)) {
|
||||
throw new Error(
|
||||
`Key-signature entry ${index + 1} has invalid "key_signature": "${trimmed}". Use an exact key-signature picker value such as "C major", "F# minor", or "Bb major".`,
|
||||
);
|
||||
}
|
||||
|
||||
return trimmed as KeySignature;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WriteMarkersTool } from './WriteMarkersTool';
|
||||
import { KGProject } from '../../core/KGProject';
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGMarkerRegion } from '../../core/region/KGMarkerRegion';
|
||||
import { findGlobalTrackByType } from '../../util/globalTrackUtil';
|
||||
import { GlobalTrackType } from '../../core/global-track';
|
||||
|
||||
function mockCore(project: KGProject) {
|
||||
vi.spyOn(KGCore, 'instance').mockReturnValue({
|
||||
getCurrentProject: () => project,
|
||||
getSelectedItems: () => [],
|
||||
executeCommand: (command: { execute(): void }) => command.execute(),
|
||||
} as unknown as KGCore);
|
||||
}
|
||||
|
||||
function getMarkerTrack(project: KGProject) {
|
||||
const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
|
||||
expect(markerTrack).not.toBeNull();
|
||||
return markerTrack!;
|
||||
}
|
||||
|
||||
describe('WriteMarkersTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('exposes the expected write-only availability and schema details', () => {
|
||||
const project = new KGProject('tool-definition-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteMarkersTool();
|
||||
const definition = tool.getDefinition();
|
||||
|
||||
expect(tool.isReadOnlyTool()).toBe(false);
|
||||
expect(tool.isAvailableInEfficientMode()).toBe(false);
|
||||
expect(definition.function.name).toBe('write_markers');
|
||||
expect(definition.function.description).toContain('annotation-only');
|
||||
expect(JSON.stringify(definition.function.parameters)).toContain('marker');
|
||||
});
|
||||
|
||||
it('writes a single marker into an empty marker track and normalizes the label', async () => {
|
||||
const project = new KGProject('single-write-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteMarkersTool();
|
||||
const result = await tool.execute({
|
||||
markers: [{ marker: ' Intro\nSection ', beat: 4, length: 4 }],
|
||||
});
|
||||
|
||||
const markerTrack = getMarkerTrack(project);
|
||||
expect(result.success).toBe(true);
|
||||
expect((markerTrack.getRegions()[0] as KGMarkerRegion).getName()).toBe('Intro Section');
|
||||
expect(tool.buildToolHistoryContent({}, result)).toBe(result.result);
|
||||
expect(tool.buildToolResultDisplayContent({}, result)).toContain('Successfully wrote 1 marker annotation');
|
||||
expect(result.result).toContain('[Beat: 4; Length: 4]: Intro Section');
|
||||
});
|
||||
|
||||
it('builds a confirmation summary for the affected beat span', () => {
|
||||
const project = new KGProject('confirmation-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteMarkersTool();
|
||||
expect(tool.buildConfirmationContent({
|
||||
markers: [
|
||||
{ marker: 'Intro', beat: 0, length: 4 },
|
||||
{ marker: 'Verse', beat: 8, length: 4 },
|
||||
],
|
||||
})).toBe('Allow writing 2 marker annotations to the global Marker track from beat 0 to beat 12?');
|
||||
});
|
||||
|
||||
it('rejects an empty marker list', async () => {
|
||||
const project = new KGProject('empty-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteMarkersTool();
|
||||
const result = await tool.execute({ markers: [] });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('must contain at least one marker entry');
|
||||
});
|
||||
|
||||
it('rejects empty marker labels', async () => {
|
||||
const project = new KGProject('bad-marker-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteMarkersTool();
|
||||
const result = await tool.execute({ markers: [{ marker: ' ', beat: 0, length: 4 }] });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('invalid "marker"');
|
||||
});
|
||||
|
||||
it('rejects invalid beat and non-positive length values', async () => {
|
||||
const project = new KGProject('bad-number-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteMarkersTool();
|
||||
const badBeatResult = await tool.execute({ markers: [{ marker: 'Intro', beat: -1, length: 4 }] });
|
||||
const badLengthResult = await tool.execute({ markers: [{ marker: 'Intro', beat: 0, length: 0 }] });
|
||||
|
||||
expect(badBeatResult.success).toBe(false);
|
||||
expect(badBeatResult.result).toContain('invalid "beat"');
|
||||
expect(badLengthResult.success).toBe(false);
|
||||
expect(badLengthResult.result).toContain('invalid "length"');
|
||||
});
|
||||
|
||||
it('rejects overlapping requested marker entries', async () => {
|
||||
const project = new KGProject('overlap-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteMarkersTool();
|
||||
const result = await tool.execute({
|
||||
markers: [
|
||||
{ marker: 'Intro', beat: 0, length: 4 },
|
||||
{ marker: 'Verse', beat: 3, length: 4 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.result).toContain('overlaps with marker entry 1');
|
||||
});
|
||||
|
||||
it('replaces overlapping existing markers while preserving untouched regions', async () => {
|
||||
const project = new KGProject('preserve-project', 8, 0, 120, { numerator: 4, denominator: 4 }, 'C major');
|
||||
const markerTrack = getMarkerTrack(project);
|
||||
markerTrack.setRegions([
|
||||
new KGMarkerRegion('marker-1', markerTrack.getId(), markerTrack.getTrackIndex(), 'Long Intro', 0, 8),
|
||||
new KGMarkerRegion('marker-2', markerTrack.getId(), markerTrack.getTrackIndex(), 'Outro', 8, 4),
|
||||
]);
|
||||
mockCore(project);
|
||||
|
||||
const tool = new WriteMarkersTool();
|
||||
const result = await tool.execute({
|
||||
markers: [
|
||||
{ marker: 'Hit', beat: 3, length: 2 },
|
||||
{ marker: 'Drop', beat: 10, length: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect((markerTrack.getRegions() as KGMarkerRegion[]).map(region => ({
|
||||
name: region.getName(),
|
||||
start: region.getStartFromBeat(),
|
||||
length: region.getLength(),
|
||||
}))).toEqual([
|
||||
{ name: 'Long Intro', start: 0, length: 3 },
|
||||
{ name: 'Hit', start: 3, length: 2 },
|
||||
{ name: 'Long Intro', start: 5, length: 3 },
|
||||
{ name: 'Outro', start: 8, length: 2 },
|
||||
{ name: 'Drop', start: 10, length: 2 },
|
||||
]);
|
||||
expect(result.result).toContain('annotation-only');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { BaseTool } from './BaseTool';
|
||||
import type { ToolParameter, ToolResult } from './BaseTool';
|
||||
import {
|
||||
WriteMarkersCommand,
|
||||
type WriteMarkerEntry,
|
||||
} from '../../core/commands/global-region/WriteMarkersCommand';
|
||||
|
||||
interface RequestedMarkerEntry {
|
||||
marker: string;
|
||||
beat: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
interface ValidatedMarkerEntry extends WriteMarkerEntry {
|
||||
marker: string;
|
||||
}
|
||||
|
||||
export class WriteMarkersTool extends BaseTool {
|
||||
readonly name = 'write_markers';
|
||||
readonly description = 'Write marker annotations to the global Marker track using absolute beat positions on the project timeline. Marker regions are annotation-only and do not affect playback.';
|
||||
|
||||
override isReadOnlyTool(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
override isAvailableInEfficientMode(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
readonly parameters: Record<string, ToolParameter> = {
|
||||
markers: {
|
||||
type: 'array',
|
||||
description: 'Marker annotations to write to the global Marker track. Each entry uses an absolute beat start on the project timeline.',
|
||||
required: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
description: 'A single marker annotation region.',
|
||||
properties: {
|
||||
marker: {
|
||||
type: 'string',
|
||||
description: 'Marker label text. Must be non-empty after trimming.',
|
||||
required: true,
|
||||
},
|
||||
beat: {
|
||||
type: 'number',
|
||||
description: 'Start beat on the absolute project timeline. This is not relative to a clip or region.',
|
||||
required: true,
|
||||
},
|
||||
length: {
|
||||
type: 'number',
|
||||
description: 'Marker duration in beats. Must be greater than 0.',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
override buildToolResultDisplayContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
return this.formatMultilineResult(toolResult.result);
|
||||
}
|
||||
|
||||
override buildToolHistoryContent(_args: Record<string, unknown> | null, toolResult: ToolResult): string | undefined {
|
||||
return toolResult.result;
|
||||
}
|
||||
|
||||
override buildConfirmationContent(args: Record<string, unknown> | null): string | undefined {
|
||||
if (!args) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const validatedMarkers = this.validateAndNormalizeMarkers(args.markers as RequestedMarkerEntry[]);
|
||||
const firstBeat = validatedMarkers[0].startBeat;
|
||||
const lastBeatExclusive = Math.max(...validatedMarkers.map(marker => marker.startBeat + marker.length));
|
||||
return `Allow writing ${validatedMarkers.length} marker ${validatedMarkers.length === 1 ? 'annotation' : 'annotations'} to the global Marker track from beat ${firstBeat} to beat ${lastBeatExclusive}?`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
try {
|
||||
this.validateParameters(params);
|
||||
|
||||
const validatedMarkers = this.validateAndNormalizeMarkers(params.markers as RequestedMarkerEntry[]);
|
||||
await this.executeCommand(new WriteMarkersCommand(validatedMarkers.map(marker => ({
|
||||
startBeat: marker.startBeat,
|
||||
length: marker.length,
|
||||
name: marker.marker,
|
||||
}))));
|
||||
|
||||
const details = validatedMarkers
|
||||
.map(marker => `[Beat: ${marker.startBeat}; Length: ${marker.length}]: ${marker.marker}`)
|
||||
.join('\n');
|
||||
|
||||
return this.createSuccessResult(
|
||||
`Successfully wrote ${validatedMarkers.length} marker ${validatedMarkers.length === 1 ? 'annotation' : 'annotations'} to the global Marker track. Markers are annotation-only and do not affect playback.\n${details}`,
|
||||
);
|
||||
} catch (error) {
|
||||
return this.createErrorResult(`Failed to write markers: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private validateAndNormalizeMarkers(markers: RequestedMarkerEntry[]): ValidatedMarkerEntry[] {
|
||||
if (markers.length === 0) {
|
||||
throw new Error('Parameter "markers" must contain at least one marker entry.');
|
||||
}
|
||||
|
||||
const validated = markers.map((marker, index) => this.validateMarkerEntry(marker, index));
|
||||
validated.sort((left, right) => left.startBeat - right.startBeat);
|
||||
|
||||
for (let index = 1; index < validated.length; index += 1) {
|
||||
const previous = validated[index - 1];
|
||||
const current = validated[index];
|
||||
if (current.startBeat < previous.startBeat + previous.length) {
|
||||
throw new Error(
|
||||
`Marker entry ${index + 1} overlaps with marker entry ${index}. Entry ${index} ends at beat ${previous.startBeat + previous.length}, but entry ${index + 1} starts at beat ${current.startBeat}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return validated;
|
||||
}
|
||||
|
||||
private validateMarkerEntry(marker: RequestedMarkerEntry, index: number): ValidatedMarkerEntry {
|
||||
if (!Number.isFinite(marker.beat)) {
|
||||
throw new Error(`Marker entry ${index + 1} has invalid "beat": ${String(marker.beat)}. Expected a finite number >= 0.`);
|
||||
}
|
||||
if (marker.beat < 0) {
|
||||
throw new Error(`Marker entry ${index + 1} has invalid "beat": ${marker.beat}. Expected a value >= 0.`);
|
||||
}
|
||||
if (!Number.isFinite(marker.length)) {
|
||||
throw new Error(`Marker entry ${index + 1} has invalid "length": ${String(marker.length)}. Expected a finite number > 0.`);
|
||||
}
|
||||
if (marker.length <= 0) {
|
||||
throw new Error(`Marker entry ${index + 1} has invalid "length": ${marker.length}. Expected a value > 0.`);
|
||||
}
|
||||
|
||||
const normalizedMarker = marker.marker?.replace(/\r?\n/g, ' ').trim();
|
||||
if (!normalizedMarker) {
|
||||
throw new Error(`Marker entry ${index + 1} has invalid "marker": expected a non-empty marker label.`);
|
||||
}
|
||||
|
||||
return {
|
||||
marker: normalizedMarker,
|
||||
name: normalizedMarker,
|
||||
startBeat: marker.beat,
|
||||
length: marker.length,
|
||||
};
|
||||
}
|
||||
|
||||
private formatMultilineResult(result: string): string {
|
||||
return result.replace(/\n/g, ' \n');
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,86 @@
|
||||
// Base tool system
|
||||
import { BaseTool } from './BaseTool';
|
||||
export { BaseTool } from './BaseTool';
|
||||
export type { ToolResult, ToolParameter, ToolDefinition, OpenAIToolDefinition, OpenAIFunctionParameters } from './BaseTool';
|
||||
|
||||
// Specific tools
|
||||
import { AddNotesTool } from './AddNotesTool';
|
||||
import { RemoveNotesTool } from './RemoveNotesTool';
|
||||
import { RemoveChordProgressionTool } from './RemoveChordProgressionTool';
|
||||
import { RemoveMarkersTool } from './RemoveMarkersTool';
|
||||
import { RemoveKeySignatureTool } from './RemoveKeySignatureTool';
|
||||
import { RemoveBpmTool } from './RemoveBpmTool';
|
||||
import { ReadMusicTool } from './ReadMusicTool';
|
||||
import { ReadMarkersTool } from './ReadMarkersTool';
|
||||
import { ReadChordProgressionTool } from './ReadChordProgressionTool';
|
||||
import { WriteChordProgressionTool } from './WriteChordProgressionTool';
|
||||
import { WriteMarkersTool } from './WriteMarkersTool';
|
||||
import { ReadKeySignatureTool } from './ReadKeySignatureTool';
|
||||
import { ReadBpmTool } from './ReadBpmTool';
|
||||
import { WriteKeySignatureTool } from './WriteKeySignatureTool';
|
||||
import { WriteBpmTool } from './WriteBpmTool';
|
||||
import { UpdateTodoListTool } from './UpdateTodoListTool';
|
||||
import { GetUserSelectedMusicRangeAndTrackTool } from './GetUserSelectedMusicRangeAndTrackTool';
|
||||
import { ListAllTracksTool } from './ListAllTracksTool';
|
||||
import { ListAllAvailableInstrumentsTool } from './ListAllAvailableInstrumentsTool';
|
||||
import { CreateNewTrackTool } from './CreateNewTrackTool';
|
||||
import { UpdateTrackTool } from './UpdateTrackTool';
|
||||
import { DeleteTrackTool } from './DeleteTrackTool';
|
||||
|
||||
export { AddNotesTool, RemoveNotesTool, ReadMusicTool, ReadChordProgressionTool };
|
||||
export {
|
||||
AddNotesTool,
|
||||
RemoveNotesTool,
|
||||
RemoveChordProgressionTool,
|
||||
RemoveMarkersTool,
|
||||
RemoveKeySignatureTool,
|
||||
RemoveBpmTool,
|
||||
ReadMusicTool,
|
||||
ReadMarkersTool,
|
||||
ReadChordProgressionTool,
|
||||
WriteChordProgressionTool,
|
||||
WriteMarkersTool,
|
||||
ReadKeySignatureTool,
|
||||
ReadBpmTool,
|
||||
WriteKeySignatureTool,
|
||||
WriteBpmTool,
|
||||
UpdateTodoListTool,
|
||||
GetUserSelectedMusicRangeAndTrackTool,
|
||||
ListAllTracksTool,
|
||||
ListAllAvailableInstrumentsTool,
|
||||
CreateNewTrackTool,
|
||||
UpdateTrackTool,
|
||||
DeleteTrackTool,
|
||||
};
|
||||
|
||||
// Tool registry for easy access
|
||||
export const AVAILABLE_TOOLS = {
|
||||
update_todo_list: UpdateTodoListTool,
|
||||
add_notes: AddNotesTool,
|
||||
remove_notes: RemoveNotesTool,
|
||||
remove_chord_progression: RemoveChordProgressionTool,
|
||||
remove_markers: RemoveMarkersTool,
|
||||
remove_key_signature: RemoveKeySignatureTool,
|
||||
remove_bpm: RemoveBpmTool,
|
||||
read_music: ReadMusicTool,
|
||||
read_markers: ReadMarkersTool,
|
||||
read_chord_progression: ReadChordProgressionTool,
|
||||
write_chord_progression: WriteChordProgressionTool,
|
||||
write_markers: WriteMarkersTool,
|
||||
read_key_signature: ReadKeySignatureTool,
|
||||
read_bpm: ReadBpmTool,
|
||||
write_key_signature: WriteKeySignatureTool,
|
||||
write_bpm: WriteBpmTool,
|
||||
get_user_selected_music_range_and_track: GetUserSelectedMusicRangeAndTrackTool,
|
||||
list_all_tracks: ListAllTracksTool,
|
||||
list_all_available_instruments: ListAllAvailableInstrumentsTool,
|
||||
create_new_track: CreateNewTrackTool,
|
||||
update_track: UpdateTrackTool,
|
||||
delete_track: DeleteTrackTool,
|
||||
} as const;
|
||||
|
||||
export type ToolName = keyof typeof AVAILABLE_TOOLS;
|
||||
|
||||
export const createToolInstance = (toolName: string): BaseTool | null => {
|
||||
const ToolClass = AVAILABLE_TOOLS[toolName as ToolName];
|
||||
return ToolClass ? new ToolClass() : null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { KGCore } from '../../core/KGCore';
|
||||
import { KGRegion } from '../../core/region/KGRegion';
|
||||
import { KGMidiRegion } from '../../core/region/KGMidiRegion';
|
||||
import { KGGlobalRegion } from '../../core/region/KGGlobalRegion';
|
||||
import { KGMidiTrack } from '../../core/track/KGMidiTrack';
|
||||
import { KGTrack } from '../../core/track/KGTrack';
|
||||
import type { InstrumentType } from '../../core/track/KGMidiTrack';
|
||||
import { useProjectStore } from '../../stores/projectStore';
|
||||
import { FLUIDR3_INSTRUMENT_MAP, INSTRUMENT_GROUPS } from '../../constants/generalMidiConstants';
|
||||
|
||||
export const NO_MIDI_TARGET_RAW_MESSAGE =
|
||||
'No MIDI target could be resolved. Select the MIDI region you want me to edit and retry, or tell me which MIDI track to operate on by providing its track_id.';
|
||||
|
||||
export const NO_MIDI_TARGET_HISTORY_MESSAGE =
|
||||
'I could not tell which MIDI content to edit. Select a MIDI region and retry, or tell me which track I should work on.';
|
||||
|
||||
export const NO_MIDI_TARGET_UI_MESSAGE =
|
||||
'Select a MIDI region, or specify a track.';
|
||||
|
||||
export interface ActiveMidiRegionContext {
|
||||
region: KGMidiRegion;
|
||||
track: KGMidiTrack;
|
||||
trackName: string;
|
||||
}
|
||||
|
||||
export interface SelectedMusicRangeContext {
|
||||
section: string;
|
||||
startBeat: number | null;
|
||||
endBeat: number | null;
|
||||
hasRange: boolean;
|
||||
}
|
||||
|
||||
export interface SelectedTrackContext {
|
||||
track: KGTrack | null;
|
||||
trackId: string | null;
|
||||
trackName: string | null;
|
||||
hasSelectedTrack: boolean;
|
||||
}
|
||||
|
||||
export function getTrackDisplayName(track: { getName(): string; getTrackIndex(): number }): string {
|
||||
return track.getName() || `Track ${track.getTrackIndex() + 1}`;
|
||||
}
|
||||
|
||||
export function resolveMidiTrackByIdOrName(
|
||||
trackId: string | undefined,
|
||||
trackName: string | undefined,
|
||||
): KGMidiTrack | null {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const midiTracks = project.getTracks().filter((track): track is KGMidiTrack => track instanceof KGMidiTrack);
|
||||
|
||||
if (trackId) {
|
||||
return midiTracks.find(track => track.getId().toString() === trackId) ?? null;
|
||||
}
|
||||
|
||||
if (trackName) {
|
||||
return midiTracks.find(track => track.getName() === trackName) ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveMidiTrackByExactName(trackName: string): KGMidiTrack[] {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
return project.getTracks().filter((track): track is KGMidiTrack => (
|
||||
track instanceof KGMidiTrack && track.getName() === trackName
|
||||
));
|
||||
}
|
||||
|
||||
export function resolveInstrumentKeyByEnglishName(instrumentName: string): InstrumentType | null {
|
||||
for (const [instrumentKey, instrumentInfo] of Object.entries(FLUIDR3_INSTRUMENT_MAP)) {
|
||||
if (instrumentInfo.displayName === instrumentName) {
|
||||
return instrumentKey as InstrumentType;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getEnglishInstrumentName(instrumentKey: InstrumentType): string {
|
||||
return FLUIDR3_INSTRUMENT_MAP[instrumentKey]?.displayName ?? String(instrumentKey);
|
||||
}
|
||||
|
||||
export function listAvailableInstrumentsByGroup(): Array<{ groupName: string; instruments: string[] }> {
|
||||
const groupedInstruments = new Map<string, string[]>();
|
||||
|
||||
for (const groupName of Object.values(INSTRUMENT_GROUPS)) {
|
||||
groupedInstruments.set(groupName, []);
|
||||
}
|
||||
|
||||
for (const instrumentInfo of Object.values(FLUIDR3_INSTRUMENT_MAP)) {
|
||||
const groupName = INSTRUMENT_GROUPS[instrumentInfo.group as keyof typeof INSTRUMENT_GROUPS];
|
||||
if (!groupedInstruments.has(groupName)) {
|
||||
groupedInstruments.set(groupName, []);
|
||||
}
|
||||
groupedInstruments.get(groupName)!.push(instrumentInfo.displayName);
|
||||
}
|
||||
|
||||
return Array.from(groupedInstruments.entries()).map(([groupName, instruments]) => ({
|
||||
groupName,
|
||||
instruments,
|
||||
}));
|
||||
}
|
||||
|
||||
export function findRegionById(regionId: string): KGRegion | null {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
|
||||
for (const track of project.getTracks()) {
|
||||
const region = track.getRegions().find(candidate => candidate.getId() === regionId);
|
||||
if (region) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
|
||||
for (const globalTrack of project.getGlobalTracks()) {
|
||||
const region = globalTrack.getRegions().find(candidate => candidate.getId() === regionId);
|
||||
if (region) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findRegularTrackByRegion(region: KGRegion): KGTrack | null {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
return project.getTracks().find(track => track.getRegions().includes(region)) ?? null;
|
||||
}
|
||||
|
||||
export function resolveSelectedMusicRangeContext(): SelectedMusicRangeContext {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const storeState = useProjectStore.getState();
|
||||
|
||||
if (project.getIsLooping()) {
|
||||
const beatsPerBar = project.getTimeSignature().numerator;
|
||||
const [loopStartBar, loopEndBar] = project.getLoopingRange();
|
||||
const startBeat = loopStartBar * beatsPerBar;
|
||||
const endBeat = (loopEndBar + 1) * beatsPerBar;
|
||||
return {
|
||||
section: `- Start Beat: ${startBeat}\n- End Beat: ${endBeat}`,
|
||||
startBeat,
|
||||
endBeat,
|
||||
hasRange: true,
|
||||
};
|
||||
}
|
||||
|
||||
const selectedRegions = (storeState.selectedRegionIds ?? [])
|
||||
.map(regionId => findRegionById(regionId))
|
||||
.filter((region): region is KGRegion => region !== null);
|
||||
|
||||
if (selectedRegions.length === 0) {
|
||||
return {
|
||||
section: '- No selected music range.',
|
||||
startBeat: null,
|
||||
endBeat: null,
|
||||
hasRange: false,
|
||||
};
|
||||
}
|
||||
|
||||
const startBeat = Math.min(...selectedRegions.map(region => region.getStartFromBeat()));
|
||||
const endBeat = Math.max(...selectedRegions.map(region => region.getStartFromBeat() + region.getLength()));
|
||||
|
||||
return {
|
||||
section: `- Start Beat: ${startBeat}\n- End Beat: ${endBeat}`,
|
||||
startBeat,
|
||||
endBeat,
|
||||
hasRange: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSelectedTrackContext(): SelectedTrackContext {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const storeState = useProjectStore.getState();
|
||||
const selectedRegionIds = storeState.selectedRegionIds ?? [];
|
||||
|
||||
if (selectedRegionIds.length === 0) {
|
||||
return {
|
||||
track: null,
|
||||
trackId: null,
|
||||
trackName: null,
|
||||
hasSelectedTrack: false,
|
||||
};
|
||||
}
|
||||
|
||||
const selectedRegions = selectedRegionIds
|
||||
.map(regionId => findRegionById(regionId))
|
||||
.filter((region): region is KGRegion => region !== null);
|
||||
|
||||
if (selectedRegions.length === 0 || selectedRegions.every(region => region instanceof KGGlobalRegion)) {
|
||||
return {
|
||||
track: null,
|
||||
trackId: null,
|
||||
trackName: null,
|
||||
hasSelectedTrack: false,
|
||||
};
|
||||
}
|
||||
|
||||
const selectedTrackId = storeState.selectedTrackId;
|
||||
const selectedTrack = selectedTrackId
|
||||
? project.getTracks().find(track => track.getId().toString() === selectedTrackId) ?? null
|
||||
: null;
|
||||
|
||||
if (!selectedTrack) {
|
||||
return {
|
||||
track: null,
|
||||
trackId: null,
|
||||
trackName: null,
|
||||
hasSelectedTrack: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
track: selectedTrack,
|
||||
trackId: selectedTrack.getId().toString(),
|
||||
trackName: getTrackDisplayName(selectedTrack),
|
||||
hasSelectedTrack: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveActiveOrSelectedMidiRegionContext(): ActiveMidiRegionContext | null {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const tracks = project.getTracks();
|
||||
const midiTracks = tracks.filter((track): track is KGMidiTrack => track instanceof KGMidiTrack);
|
||||
const storeState = useProjectStore.getState();
|
||||
|
||||
if (storeState.activeRegionId) {
|
||||
for (const track of midiTracks) {
|
||||
const region = track.getRegions().find(candidate => candidate.getId() === storeState.activeRegionId);
|
||||
if (region instanceof KGMidiRegion) {
|
||||
return {
|
||||
region,
|
||||
track,
|
||||
trackName: getTrackDisplayName(track),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selectedItems = KGCore.instance().getSelectedItems();
|
||||
for (const item of selectedItems) {
|
||||
if (!(item instanceof KGMidiRegion)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const track = midiTracks.find(candidate => candidate.getId().toString() === item.getTrackId());
|
||||
if (track) {
|
||||
return {
|
||||
region: item,
|
||||
track,
|
||||
trackName: getTrackDisplayName(track),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
+343
-1
@@ -46,6 +46,16 @@
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chatbox-toggle-btn.is-active {
|
||||
background-color: #e0e0e0;
|
||||
color: #2d2d2d;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chatbox-toggle-btn.is-active:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
/* ChatBox export button wrapper and dropdown positioning */
|
||||
.chatbox-export-wrapper {
|
||||
position: relative;
|
||||
@@ -166,6 +176,216 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chatbox-history-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chatbox-history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px 8px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.chatbox-history-header h4 {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.chatbox-history-cancel {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #9fc7ea;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chatbox-history-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chatbox-history-item {
|
||||
width: 100%;
|
||||
background: #262626;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.chatbox-history-item:hover {
|
||||
background: #2d2d2d;
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.chatbox-history-item-toprow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chatbox-history-open-btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chatbox-history-open-btn-body {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.chatbox-history-item-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chatbox-history-item-meta {
|
||||
font-size: 10px;
|
||||
color: #9a9a9a;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.chatbox-history-delete-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #b7b7b7;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.chatbox-history-delete-btn:hover {
|
||||
color: #e17070;
|
||||
}
|
||||
|
||||
.chatbox-history-item-preview {
|
||||
font-size: 11px;
|
||||
color: #c8c8c8;
|
||||
margin-top: 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chatbox-history-empty {
|
||||
color: #999;
|
||||
font-size: 11px;
|
||||
padding: 16px 6px;
|
||||
}
|
||||
|
||||
.chatbox-todo-card {
|
||||
background: linear-gradient(180deg, #252525 0%, #202020 100%);
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chatbox-todo-card-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chatbox-todo-card-header h4 {
|
||||
margin: 0;
|
||||
color: #f0f0f0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chatbox-todo-count {
|
||||
color: #8fb8da;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chatbox-todo-active {
|
||||
color: #d7d7d7;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chatbox-todo-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chatbox-todo-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
color: #d8d8d8;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chatbox-todo-item.is-completed .chatbox-todo-text {
|
||||
color: #9ba39f;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.chatbox-todo-item.is-in_progress .chatbox-todo-text {
|
||||
color: #f0f0f0;
|
||||
}
|
||||
|
||||
.chatbox-todo-marker {
|
||||
width: 10px;
|
||||
flex: 0 0 10px;
|
||||
color: #7cc2f1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chatbox-todo-item.is-completed .chatbox-todo-marker {
|
||||
color: #67c18a;
|
||||
}
|
||||
|
||||
.chatbox-todo-item.is-pending .chatbox-todo-marker {
|
||||
color: #a7a7a7;
|
||||
}
|
||||
|
||||
.chatbox-todo-status {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.chatbox-todo-status.is-success {
|
||||
color: #67c18a;
|
||||
}
|
||||
|
||||
.chatbox-todo-status.is-error {
|
||||
color: #d45a5a;
|
||||
}
|
||||
|
||||
.message-container {
|
||||
width: 100%;
|
||||
word-wrap: break-word;
|
||||
@@ -307,12 +527,134 @@
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.message-tool-result-title,
|
||||
.message-tool-summary {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.message-tool-summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.message-tool-summary-prefix {
|
||||
white-space: pre;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.message-tool-summary-content {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.message-tool-confirmation-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.message-tool-confirmation-btn {
|
||||
width: 100%;
|
||||
margin-top: 0;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.message-tool-summary-content > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.message-tool-summary-content > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.message-performance-info {
|
||||
margin-top: 8px;
|
||||
font-size: 10px;
|
||||
color: #909090;
|
||||
}
|
||||
|
||||
.tool-call-code-block {
|
||||
position: relative;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.tool-call-code-block-inner {
|
||||
overflow: hidden;
|
||||
transition: max-height 0.24s ease;
|
||||
}
|
||||
|
||||
.tool-call-code-block-inner > div {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.tool-call-code-block-inner pre {
|
||||
margin: 0 !important;
|
||||
overflow-x: auto !important;
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
|
||||
.tool-call-code-block-toggle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tool-call-code-block-toggle-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px 12px 10px;
|
||||
color: #d7e7f6;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tool-call-code-block-toggle:hover .tool-call-code-block-toggle-content {
|
||||
color: #f0f7ff;
|
||||
}
|
||||
|
||||
.tool-call-code-block-fade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(180deg, rgba(26, 26, 26, 0) 0%, rgba(26, 26, 26, 0.92) 58%, #1a1a1a 100%);
|
||||
}
|
||||
|
||||
.message-divider-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.message-divider-banner-line {
|
||||
flex: 1 1 auto;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(102, 102, 102, 0.2) 0%, rgba(112, 112, 112, 0.55) 50%, rgba(102, 102, 102, 0.2) 100%);
|
||||
}
|
||||
|
||||
.message-divider-banner-label {
|
||||
flex: 0 0 auto;
|
||||
color: #8c8c8c;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Abort link styling */
|
||||
.abort-link {
|
||||
background: none !important;
|
||||
@@ -326,7 +668,7 @@
|
||||
}
|
||||
|
||||
.abort-link:hover {
|
||||
color: #9b88ff !important;
|
||||
color: #5a9fd4 !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
|
||||
+518
-20
@@ -1,24 +1,88 @@
|
||||
import React from 'react';
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import ChatBox from './ChatBox';
|
||||
import { I18nContext } from '../i18n/I18nProvider';
|
||||
import type { ResolvedLocaleCode } from '../i18n/types';
|
||||
import { translate } from '../i18n/translate';
|
||||
import type { ChatMessage } from '../types/projectTypes';
|
||||
|
||||
const {
|
||||
agentCoreMock,
|
||||
processUserMessageMock,
|
||||
processStreamMock,
|
||||
streamProcessorCallbacks,
|
||||
clearChatHistoryAndUIMock,
|
||||
projectStoreState,
|
||||
conversationStorageMock,
|
||||
showConfirmMock,
|
||||
} = vi.hoisted(() => ({
|
||||
agentCoreMock: {
|
||||
setLLMProvider: vi.fn(),
|
||||
getLLMProvider: vi.fn(() => ({ getPreferredSystemPromptPath: vi.fn() })),
|
||||
abortCurrentRequest: vi.fn(),
|
||||
getAgentState: vi.fn(() => ({
|
||||
getMessages: vi.fn(() => []),
|
||||
getFullMessages: vi.fn(() => []),
|
||||
getConversationId: vi.fn(() => 'conv_test'),
|
||||
getTodos: vi.fn(() => []),
|
||||
subscribeTodoChanges: vi.fn(() => () => undefined),
|
||||
})),
|
||||
restoreConversation: vi.fn(),
|
||||
compactConversation: vi.fn(async () => ({ changed: true, compactedConversation: 'summary' })),
|
||||
shouldCompactBeforeNextTurn: vi.fn(async () => false),
|
||||
},
|
||||
processUserMessageMock: vi.fn(),
|
||||
processStreamMock: vi.fn(async () => ''),
|
||||
clearChatHistoryAndUIMock: vi.fn(),
|
||||
projectStoreState: {
|
||||
projectName: 'Test Project',
|
||||
toolFastForwardEnabled: false,
|
||||
setStatus: vi.fn(),
|
||||
setToolFastForwardEnabled: vi.fn(),
|
||||
toggleToolFastForwardEnabled: vi.fn(),
|
||||
},
|
||||
conversationStorageMock: {
|
||||
initialize: vi.fn(async () => undefined),
|
||||
saveConversation: vi.fn(async () => undefined),
|
||||
loadConversation: vi.fn(async () => null),
|
||||
listConversations: vi.fn(async () => []),
|
||||
deleteConversation: vi.fn(async () => undefined),
|
||||
},
|
||||
showConfirmMock: vi.fn(async () => true),
|
||||
streamProcessorCallbacks: {
|
||||
onMessageAdd: undefined as ((message: ChatMessage) => void) | undefined,
|
||||
onMessageUpdate: undefined as ((messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void) | undefined,
|
||||
onMessageRemove: undefined as ((messageId: string) => void) | undefined,
|
||||
onProcessingChange: undefined as ((isProcessing: boolean) => void) | undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
projectStoreState.setToolFastForwardEnabled.mockImplementation((enabled: boolean) => {
|
||||
projectStoreState.toolFastForwardEnabled = enabled;
|
||||
});
|
||||
projectStoreState.toggleToolFastForwardEnabled.mockImplementation(() => {
|
||||
projectStoreState.toolFastForwardEnabled = !projectStoreState.toolFastForwardEnabled;
|
||||
});
|
||||
|
||||
vi.mock('./chat', () => ({
|
||||
UserMessage: ({ content }: { content: string }) => <div>{content}</div>,
|
||||
AssistantMessage: ({ content }: { content: string }) => <div>{content}</div>,
|
||||
AssistantMessage: ({
|
||||
content,
|
||||
todoSnapshot,
|
||||
}: {
|
||||
content: string;
|
||||
todoSnapshot?: Array<{ text: string }>;
|
||||
}) => (
|
||||
<div>
|
||||
{todoSnapshot ? `TODO SNAPSHOT: ${todoSnapshot.map(todo => todo.text).join(', ')}` : content}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../agent/core/AgentCore', () => ({
|
||||
AgentCore: {
|
||||
instance: () => ({
|
||||
setLLMProvider: vi.fn(),
|
||||
getLLMProvider: vi.fn(),
|
||||
abortCurrentRequest: vi.fn(),
|
||||
getAgentState: vi.fn(() => ({ getMessages: vi.fn(() => []) })),
|
||||
}),
|
||||
instance: () => agentCoreMock,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -47,11 +111,14 @@ vi.mock('../core/config/ConfigManager', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../stores/projectStore', () => ({
|
||||
useProjectStore: {
|
||||
getState: () => ({
|
||||
setStatus: vi.fn(),
|
||||
}),
|
||||
},
|
||||
useProjectStore: Object.assign(
|
||||
((selector?: (state: typeof projectStoreState) => unknown) => (
|
||||
selector ? selector(projectStoreState) : projectStoreState
|
||||
)) as never,
|
||||
{
|
||||
getState: () => projectStoreState,
|
||||
}
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../agent/core/SystemPrompts', () => ({
|
||||
@@ -61,23 +128,41 @@ vi.mock('../agent/core/SystemPrompts', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../util/chatUtil', () => ({
|
||||
clearChatHistoryAndUI: vi.fn(),
|
||||
clearChatHistoryAndUI: clearChatHistoryAndUIMock,
|
||||
registerClearChatUICallback: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../util/messageFilter/UserMessageFilter', () => ({
|
||||
processUserMessage: vi.fn(),
|
||||
processUserMessage: processUserMessageMock,
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useStreamProcessor', () => ({
|
||||
useStreamProcessor: () => ({
|
||||
useStreamProcessor: (options: typeof streamProcessorCallbacks) => {
|
||||
streamProcessorCallbacks.onMessageAdd = options.onMessageAdd;
|
||||
streamProcessorCallbacks.onMessageUpdate = options.onMessageUpdate;
|
||||
streamProcessorCallbacks.onMessageRemove = options.onMessageRemove;
|
||||
streamProcessorCallbacks.onProcessingChange = options.onProcessingChange;
|
||||
|
||||
return {
|
||||
abortController: null,
|
||||
processStream: vi.fn(),
|
||||
}),
|
||||
processStream: processStreamMock,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../utils/chatMessageUtils', () => ({
|
||||
createMessage: vi.fn(),
|
||||
createMessage: vi.fn((role: 'user' | 'assistant', content: string) => ({
|
||||
id: `${role}-${content}`,
|
||||
role,
|
||||
content,
|
||||
})),
|
||||
createStreamingMessage: vi.fn(() => ({
|
||||
id: 'streaming-message',
|
||||
role: 'assistant',
|
||||
content: '<span class="processing-wave">Thinking...</span> click here to abort.',
|
||||
isStreaming: true,
|
||||
tokenCount: 0,
|
||||
})),
|
||||
addWelcomeMessage: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
@@ -110,6 +195,20 @@ vi.mock('../util/localLLMConfig', () => ({
|
||||
LOCAL_LLM_PROVIDER_KEY: 'local_browser',
|
||||
}));
|
||||
|
||||
vi.mock('../core/io/KGConversationStorage', () => ({
|
||||
KGConversationStorage: {
|
||||
getInstance: () => conversationStorageMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../util/dialogUtil', async () => {
|
||||
const actual = await vi.importActual('../util/dialogUtil');
|
||||
return {
|
||||
...actual,
|
||||
showConfirm: showConfirmMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./common/KGDropdown', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
@@ -134,6 +233,38 @@ describe('ChatBox', () => {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
processUserMessageMock.mockReset();
|
||||
processStreamMock.mockClear();
|
||||
clearChatHistoryAndUIMock.mockClear();
|
||||
conversationStorageMock.initialize.mockClear();
|
||||
conversationStorageMock.saveConversation.mockClear();
|
||||
conversationStorageMock.loadConversation.mockClear();
|
||||
conversationStorageMock.listConversations.mockClear();
|
||||
conversationStorageMock.deleteConversation.mockClear();
|
||||
showConfirmMock.mockClear();
|
||||
showConfirmMock.mockResolvedValue(true);
|
||||
agentCoreMock.compactConversation.mockClear();
|
||||
agentCoreMock.shouldCompactBeforeNextTurn.mockResolvedValue(false);
|
||||
agentCoreMock.restoreConversation.mockClear();
|
||||
streamProcessorCallbacks.onMessageAdd = undefined;
|
||||
streamProcessorCallbacks.onMessageUpdate = undefined;
|
||||
streamProcessorCallbacks.onMessageRemove = undefined;
|
||||
streamProcessorCallbacks.onProcessingChange = undefined;
|
||||
projectStoreState.projectName = 'Test Project';
|
||||
projectStoreState.toolFastForwardEnabled = false;
|
||||
projectStoreState.setStatus.mockClear();
|
||||
projectStoreState.setToolFastForwardEnabled.mockClear();
|
||||
projectStoreState.toggleToolFastForwardEnabled.mockClear();
|
||||
agentCoreMock.getAgentState.mockReturnValue({
|
||||
getMessages: vi.fn(() => []),
|
||||
getFullMessages: vi.fn(() => []),
|
||||
getConversationId: vi.fn(() => 'conv_test'),
|
||||
getTodos: vi.fn(() => []),
|
||||
subscribeTodoChanges: vi.fn(() => () => undefined),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the English assistant title under en_us', () => {
|
||||
renderWithLocale('en_us');
|
||||
|
||||
@@ -151,4 +282,371 @@ describe('ChatBox', () => {
|
||||
|
||||
expect(screen.getByRole('heading', { level: 3, name: 'Assistant musical K.G.Studio' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows compacting status and completion for /compact', async () => {
|
||||
processUserMessageMock.mockResolvedValue({
|
||||
displayUserMessage: false,
|
||||
sendToLLM: false,
|
||||
finalMessageForLLM: null,
|
||||
pseudoAssistantResponse: null,
|
||||
metadata: {
|
||||
command: 'compact',
|
||||
focus: 'keep the latest work',
|
||||
},
|
||||
});
|
||||
|
||||
renderWithLocale('en_us');
|
||||
|
||||
const input = screen.getByPlaceholderText('Press Enter to send message, Shift + Enter for new line');
|
||||
fireEvent.change(input, { target: { value: '/compact keep the latest work' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', shiftKey: false });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(agentCoreMock.compactConversation).toHaveBeenCalled();
|
||||
expect(screen.getByText('Conversation Compacted')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render a pinned todo checklist from agent state', async () => {
|
||||
renderWithLocale('en_us');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Task Checklist')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('removes older incomplete todo snapshots before appending a new one', async () => {
|
||||
processUserMessageMock.mockResolvedValue({
|
||||
displayUserMessage: false,
|
||||
sendToLLM: true,
|
||||
finalMessageForLLM: 'todo prompt',
|
||||
pseudoAssistantResponse: null,
|
||||
metadata: null,
|
||||
});
|
||||
processStreamMock.mockImplementation(async () => {
|
||||
streamProcessorCallbacks.onMessageAdd?.({
|
||||
id: 'todo-1',
|
||||
role: 'assistant',
|
||||
content: 'todo 1',
|
||||
toolName: 'update_todo_list',
|
||||
toolSuccess: true,
|
||||
todoSnapshot: [
|
||||
{ id: '1', text: 'Inspect melody', status: 'completed', updatedAt: 1 },
|
||||
{ id: '2', text: 'Write harmony', status: 'in_progress', updatedAt: 2 },
|
||||
],
|
||||
});
|
||||
streamProcessorCallbacks.onMessageAdd?.({
|
||||
id: 'todo-2',
|
||||
role: 'assistant',
|
||||
content: 'todo 2',
|
||||
toolName: 'update_todo_list',
|
||||
toolSuccess: true,
|
||||
todoSnapshot: [
|
||||
{ id: '1', text: 'Inspect melody', status: 'completed', updatedAt: 3 },
|
||||
{ id: '2', text: 'Write bass', status: 'pending', updatedAt: 4 },
|
||||
],
|
||||
});
|
||||
return '';
|
||||
});
|
||||
|
||||
renderWithLocale('en_us');
|
||||
|
||||
const input = screen.getByPlaceholderText('Press Enter to send message, Shift + Enter for new line');
|
||||
fireEvent.change(input, { target: { value: 'todo cleanup' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', shiftKey: false });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('TODO SNAPSHOT: Inspect melody, Write harmony')).toBeNull();
|
||||
expect(screen.getByText('TODO SNAPSHOT: Inspect melody, Write bass')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves completed todo snapshots when a new incomplete snapshot is added', async () => {
|
||||
processUserMessageMock.mockResolvedValue({
|
||||
displayUserMessage: false,
|
||||
sendToLLM: true,
|
||||
finalMessageForLLM: 'todo prompt',
|
||||
pseudoAssistantResponse: null,
|
||||
metadata: null,
|
||||
});
|
||||
processStreamMock.mockImplementation(async () => {
|
||||
streamProcessorCallbacks.onMessageAdd?.({
|
||||
id: 'todo-complete',
|
||||
role: 'assistant',
|
||||
content: 'done snapshot',
|
||||
toolName: 'update_todo_list',
|
||||
toolSuccess: true,
|
||||
todoSnapshot: [
|
||||
{ id: '1', text: 'Inspect melody', status: 'completed', updatedAt: 1 },
|
||||
{ id: '2', text: 'Write harmony', status: 'completed', updatedAt: 2 },
|
||||
],
|
||||
});
|
||||
streamProcessorCallbacks.onMessageAdd?.({
|
||||
id: 'todo-active',
|
||||
role: 'assistant',
|
||||
content: 'active snapshot',
|
||||
toolName: 'update_todo_list',
|
||||
toolSuccess: true,
|
||||
todoSnapshot: [
|
||||
{ id: '1', text: 'Mix stems', status: 'completed', updatedAt: 3 },
|
||||
{ id: '2', text: 'Render bounce', status: 'in_progress', updatedAt: 4 },
|
||||
],
|
||||
});
|
||||
return '';
|
||||
});
|
||||
|
||||
renderWithLocale('en_us');
|
||||
|
||||
const input = screen.getByPlaceholderText('Press Enter to send message, Shift + Enter for new line');
|
||||
fireEvent.change(input, { target: { value: 'todo preserve' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', shiftKey: false });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TODO SNAPSHOT: Inspect melody, Write harmony')).toBeTruthy();
|
||||
expect(screen.getByText('TODO SNAPSHOT: Mix stems, Render bounce')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not remove non-todo assistant messages during todo cleanup', async () => {
|
||||
processUserMessageMock.mockResolvedValue({
|
||||
displayUserMessage: false,
|
||||
sendToLLM: true,
|
||||
finalMessageForLLM: 'todo prompt',
|
||||
pseudoAssistantResponse: null,
|
||||
metadata: null,
|
||||
});
|
||||
processStreamMock.mockImplementation(async () => {
|
||||
streamProcessorCallbacks.onMessageAdd?.({
|
||||
id: 'assistant-note',
|
||||
role: 'assistant',
|
||||
content: 'Normal assistant message',
|
||||
});
|
||||
streamProcessorCallbacks.onMessageAdd?.({
|
||||
id: 'todo-1',
|
||||
role: 'assistant',
|
||||
content: 'todo 1',
|
||||
toolName: 'update_todo_list',
|
||||
toolSuccess: true,
|
||||
todoSnapshot: [
|
||||
{ id: '1', text: 'Inspect melody', status: 'pending', updatedAt: 1 },
|
||||
],
|
||||
});
|
||||
streamProcessorCallbacks.onMessageAdd?.({
|
||||
id: 'todo-2',
|
||||
role: 'assistant',
|
||||
content: 'todo 2',
|
||||
toolName: 'update_todo_list',
|
||||
toolSuccess: true,
|
||||
todoSnapshot: [
|
||||
{ id: '1', text: 'Render bounce', status: 'in_progress', updatedAt: 2 },
|
||||
],
|
||||
});
|
||||
return '';
|
||||
});
|
||||
|
||||
renderWithLocale('en_us');
|
||||
|
||||
const input = screen.getByPlaceholderText('Press Enter to send message, Shift + Enter for new line');
|
||||
fireEvent.change(input, { target: { value: 'todo cleanup keep assistant' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', shiftKey: false });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Normal assistant message')).toBeTruthy();
|
||||
expect(screen.queryByText('TODO SNAPSHOT: Inspect melody')).toBeNull();
|
||||
expect(screen.getByText('TODO SNAPSHOT: Render bounce')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders and toggles the fast-forward button state', () => {
|
||||
const { rerender } = renderWithLocale('en_us');
|
||||
|
||||
const button = screen.getByTitle('Fast forward tool execution approvals');
|
||||
expect(button).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
fireEvent.click(button);
|
||||
rerender(
|
||||
<I18nContext.Provider
|
||||
value={{
|
||||
languageSetting: 'en_us',
|
||||
resolvedLocale: 'en_us',
|
||||
setLanguageSetting: async () => undefined,
|
||||
t: (key, params) => translate(key, params, 'en_us'),
|
||||
}}
|
||||
>
|
||||
<ChatBox isVisible={true} />
|
||||
</I18nContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTitle('Fast forward tool execution approvals')).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('resets fast-forward through the shared new chat clear path', async () => {
|
||||
projectStoreState.toolFastForwardEnabled = true;
|
||||
clearChatHistoryAndUIMock.mockImplementation(() => {
|
||||
projectStoreState.setToolFastForwardEnabled(false);
|
||||
});
|
||||
|
||||
const { rerender } = renderWithLocale('en_us');
|
||||
fireEvent.click(screen.getByTitle('New Chat'));
|
||||
|
||||
rerender(
|
||||
<I18nContext.Provider
|
||||
value={{
|
||||
languageSetting: 'en_us',
|
||||
resolvedLocale: 'en_us',
|
||||
setLanguageSetting: async () => undefined,
|
||||
t: (key, params) => translate(key, params, 'en_us'),
|
||||
}}
|
||||
>
|
||||
<ChatBox isVisible={true} />
|
||||
</I18nContext.Provider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(clearChatHistoryAndUIMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
rerender(
|
||||
<I18nContext.Provider
|
||||
value={{
|
||||
languageSetting: 'en_us',
|
||||
resolvedLocale: 'en_us',
|
||||
setLanguageSetting: async () => undefined,
|
||||
t: (key, params) => translate(key, params, 'en_us'),
|
||||
}}
|
||||
>
|
||||
<ChatBox isVisible={true} />
|
||||
</I18nContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTitle('Fast forward tool execution approvals')).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('autosaves a completed conversation after sending', async () => {
|
||||
agentCoreMock.getAgentState.mockReturnValue({
|
||||
getMessages: vi.fn(() => [{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 }]),
|
||||
getFullMessages: vi.fn(() => [
|
||||
{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 },
|
||||
{ id: 'm2', role: 'assistant', content: 'world', timestamp: 2 },
|
||||
]),
|
||||
getConversationId: vi.fn(() => 'conv_saved'),
|
||||
getTodos: vi.fn(() => []),
|
||||
subscribeTodoChanges: vi.fn(() => () => undefined),
|
||||
});
|
||||
processUserMessageMock.mockResolvedValue({
|
||||
displayUserMessage: true,
|
||||
sendToLLM: true,
|
||||
finalMessageForLLM: 'hello',
|
||||
pseudoAssistantResponse: null,
|
||||
metadata: null,
|
||||
});
|
||||
processStreamMock.mockImplementation(async () => {
|
||||
streamProcessorCallbacks.onMessageAdd?.({ id: 'assistant-1', role: 'assistant', content: 'world' });
|
||||
return 'world';
|
||||
});
|
||||
|
||||
renderWithLocale('en_us');
|
||||
|
||||
const input = screen.getByPlaceholderText('Press Enter to send message, Shift + Enter for new line');
|
||||
fireEvent.change(input, { target: { value: 'hello' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', shiftKey: false });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(conversationStorageMock.saveConversation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const persistedDocument = ((conversationStorageMock.saveConversation.mock.calls[0] as unknown) as [string, { displayTranscript: ChatMessage[] }])[1];
|
||||
expect(persistedDocument.displayTranscript).toEqual([
|
||||
expect.objectContaining({ role: 'user', content: 'hello' }),
|
||||
expect.objectContaining({ role: 'assistant', content: 'world' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads and restores a selected saved conversation from history', async () => {
|
||||
conversationStorageMock.listConversations.mockResolvedValue([
|
||||
{
|
||||
conversationId: 'conv_old',
|
||||
title: 'Earlier conversation',
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
lastTurnAt: 2,
|
||||
messageCount: 2,
|
||||
preview: 'Preview',
|
||||
},
|
||||
] as never);
|
||||
conversationStorageMock.loadConversation.mockResolvedValue({
|
||||
meta: {
|
||||
conversationId: 'conv_old',
|
||||
title: 'Earlier conversation',
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
lastTurnAt: 2,
|
||||
messageCount: 2,
|
||||
preview: 'Preview',
|
||||
},
|
||||
document: {
|
||||
version: 1,
|
||||
conversationId: 'conv_old',
|
||||
continuationState: {
|
||||
messages: [{ id: 'a', role: 'user', content: 'prompt', timestamp: 1 }],
|
||||
todos: [],
|
||||
},
|
||||
fullHistory: {
|
||||
messages: [
|
||||
{ id: 'a', role: 'user', content: 'prompt', timestamp: 1 },
|
||||
{ id: 'b', role: 'assistant', content: 'reply', timestamp: 2 },
|
||||
],
|
||||
},
|
||||
displayTranscript: [
|
||||
{ id: 'display-a', role: 'user', content: 'prompt' },
|
||||
{ id: 'display-b', role: 'assistant', content: 'reply' },
|
||||
],
|
||||
},
|
||||
} as never);
|
||||
|
||||
renderWithLocale('en_us');
|
||||
|
||||
fireEvent.click(screen.getByTitle('Conversation history'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Earlier conversation')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Earlier conversation'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(agentCoreMock.restoreConversation).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('prompt')).toBeTruthy();
|
||||
expect(screen.getByText('reply')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes a saved conversation after confirmation', async () => {
|
||||
conversationStorageMock.listConversations.mockResolvedValue([
|
||||
{
|
||||
conversationId: 'conv_old',
|
||||
title: 'Earlier conversation',
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
lastTurnAt: 2,
|
||||
messageCount: 2,
|
||||
preview: 'Preview',
|
||||
},
|
||||
] as never);
|
||||
|
||||
renderWithLocale('en_us');
|
||||
|
||||
fireEvent.click(screen.getByTitle('Conversation history'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Earlier conversation')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Delete'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showConfirmMock).toHaveBeenCalledTimes(1);
|
||||
expect(conversationStorageMock.deleteConversation).toHaveBeenCalledWith('Test Project', 'conv_old');
|
||||
expect(screen.queryByText('Earlier conversation')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+473
-27
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useRef, useEffect, memo, useCallback } from 'react';
|
||||
import './ChatBox.css';
|
||||
import { FaPlus, FaBan, FaDownload } from 'react-icons/fa';
|
||||
import { FaPlus, FaDownload, FaForward, FaHistory, FaTrash } from 'react-icons/fa';
|
||||
import { UserMessage, AssistantMessage } from './chat';
|
||||
import { AgentCore } from '../agent/core/AgentCore';
|
||||
import { summarizeTodoCounts } from '../agent/core/todo';
|
||||
import { OpenAICompatibleLLMProvider, type LLMProvider } from '../agent/llm/LLMProvider';
|
||||
import { LocalBrowserLLMProvider } from '../agent/llm/LocalBrowserLLMProvider';
|
||||
import { ConfigManager } from '../core/config/ConfigManager';
|
||||
@@ -11,18 +12,106 @@ import { SystemPrompts } from '../agent/core/SystemPrompts';
|
||||
import { clearChatHistoryAndUI, registerClearChatUICallback } from '../util/chatUtil';
|
||||
import { processUserMessage } from '../util/messageFilter/UserMessageFilter';
|
||||
import { useStreamProcessor } from '../hooks/useStreamProcessor';
|
||||
import { createMessage, addWelcomeMessage } from '../utils/chatMessageUtils';
|
||||
import { createMessage, createStreamingMessage, addWelcomeMessage } from '../utils/chatMessageUtils';
|
||||
import { formatLocalDateTime } from '../util/timeUtil';
|
||||
import { downloadBlob, buildTimestampSuffix } from '../util/miscUtil';
|
||||
import { LocalLLMModelManager, type LocalLLMModelState } from '../util/localLLMModelManager';
|
||||
import { LOCAL_LLM_DISPLAY_NAME, LOCAL_LLM_PROVIDER_KEY } from '../util/localLLMConfig';
|
||||
import KGDropdown from './common/KGDropdown';
|
||||
import { useI18n } from '../i18n/useI18n';
|
||||
import { KGConversationStorage } from '../core/io/KGConversationStorage';
|
||||
import { SAVED_CONVERSATION_VERSION, type SavedConversationDocument, type SavedConversationMeta } from '../types/conversationTypes';
|
||||
import type { Message } from '../agent/core/AgentState';
|
||||
import { showConfirm } from '../util/dialogUtil';
|
||||
import { getEffectiveAgentMode, getSystemPromptPathForAgentMode } from '../util/agentMode';
|
||||
|
||||
import type { ChatMessage } from '../types/projectTypes';
|
||||
|
||||
// Module-level guard to avoid duplicate welcome in React StrictMode dev remounts
|
||||
let hasShownWelcomeOnceInRuntime = false;
|
||||
const TODO_TOOL_NAME = 'update_todo_list';
|
||||
const HISTORY_TITLE_MAX_LENGTH = 48;
|
||||
const HISTORY_PREVIEW_MAX_LENGTH = 96;
|
||||
|
||||
const isCompletedTodoSnapshotMessage = (message: ChatMessage): boolean => {
|
||||
if (message.toolName !== TODO_TOOL_NAME || !Array.isArray(message.todoSnapshot)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const counts = summarizeTodoCounts(message.todoSnapshot);
|
||||
return counts.total > 0 && counts.completed === counts.total;
|
||||
};
|
||||
|
||||
const truncateWithEllipsis = (value: string, maxLength: number): string => {
|
||||
const normalized = value.trim();
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
|
||||
};
|
||||
|
||||
const stripDisplayText = (value: string | null | undefined): string => {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return value
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/[`*_>#-]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const extractFirstLine = (value: string | null | undefined): string => {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const [firstLine] = value.split(/\r?\n/, 1);
|
||||
return stripDisplayText(firstLine);
|
||||
};
|
||||
|
||||
const toDurableChatMessage = (message: ChatMessage): ChatMessage | null => {
|
||||
if (message.isStreaming || message.toolConfirmation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { onToolConfirmationDecision, ...persisted } = message;
|
||||
return persisted;
|
||||
};
|
||||
|
||||
const buildConversationMeta = (
|
||||
conversationId: string,
|
||||
fullHistoryMessages: Message[],
|
||||
displayTranscript: ChatMessage[],
|
||||
): SavedConversationMeta => {
|
||||
const firstDisplayUserMessage = displayTranscript.find((message) => (
|
||||
message.role === 'user' && stripDisplayText(message.content)
|
||||
));
|
||||
const firstUserMessage = firstDisplayUserMessage
|
||||
?? fullHistoryMessages.find((message) => message.role === 'user' && stripDisplayText(message.content));
|
||||
const previewSource = [...displayTranscript]
|
||||
.reverse()
|
||||
.map((message) => stripDisplayText(message.content))
|
||||
.find(Boolean)
|
||||
|| [...fullHistoryMessages]
|
||||
.reverse()
|
||||
.map((message) => stripDisplayText(message.content))
|
||||
.find(Boolean)
|
||||
|| '';
|
||||
const createdAt = fullHistoryMessages[0]?.timestamp ?? Date.now();
|
||||
const lastTurnAt = fullHistoryMessages[fullHistoryMessages.length - 1]?.timestamp ?? createdAt;
|
||||
|
||||
return {
|
||||
conversationId,
|
||||
title: truncateWithEllipsis(extractFirstLine(firstUserMessage?.content) || 'Untitled conversation', HISTORY_TITLE_MAX_LENGTH),
|
||||
createdAt,
|
||||
updatedAt: Date.now(),
|
||||
lastTurnAt,
|
||||
messageCount: fullHistoryMessages.length,
|
||||
preview: truncateWithEllipsis(previewSource, HISTORY_PREVIEW_MAX_LENGTH),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the LLM provider from current configuration
|
||||
@@ -65,11 +154,15 @@ interface ChatBoxProps {
|
||||
|
||||
const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
const { t } = useI18n();
|
||||
const toolFastForwardEnabled = useProjectStore((state) => state.toolFastForwardEnabled);
|
||||
const toggleToolFastForwardEnabled = useProjectStore((state) => state.toggleToolFastForwardEnabled);
|
||||
const projectName = useProjectStore((state) => state.projectName);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isCompacting, setIsCompacting] = useState(false);
|
||||
const [lastUserMessage, setLastUserMessage] = useState<string>('');
|
||||
const [localModelState, setLocalModelState] = useState<LocalLLMModelState>(LocalLLMModelManager.getState());
|
||||
const [activeProvider, setActiveProvider] = useState<string>('openai');
|
||||
@@ -79,15 +172,20 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
|
||||
// Export dropdown state and options
|
||||
const [showExportDropdown, setShowExportDropdown] = useState(false);
|
||||
const [showHistoryPanel, setShowHistoryPanel] = useState(false);
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||
const [conversationHistory, setConversationHistory] = useState<SavedConversationMeta[]>([]);
|
||||
const exportOptions = [
|
||||
'Export conversation as JSON',
|
||||
'Export conversation as Markdown'
|
||||
];
|
||||
const lastPersistedConversationKeyRef = useRef<string | null>(null);
|
||||
const messagesRef = useRef<ChatMessage[]>([]);
|
||||
|
||||
const handleExportOptionSelect = (option: string) => {
|
||||
if (option === 'Export conversation as JSON') {
|
||||
try {
|
||||
const agentMessages = AgentCore.instance().getAgentState().getMessages();
|
||||
const agentMessages = AgentCore.instance().getAgentState().getFullMessages();
|
||||
const exportMessages = agentMessages.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
@@ -105,7 +203,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
} else if (option === 'Export conversation as Markdown') {
|
||||
(async () => {
|
||||
try {
|
||||
const agentMessages = AgentCore.instance().getAgentState().getMessages();
|
||||
const agentMessages = AgentCore.instance().getAgentState().getFullMessages();
|
||||
const templateUrl = `${import.meta.env.BASE_URL}chat/export_conversation_template.md`;
|
||||
const res = await fetch(templateUrl);
|
||||
const template = await res.text();
|
||||
@@ -146,15 +244,38 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
|
||||
// Message update callbacks for stream processor
|
||||
const handleMessageUpdate = useCallback((messageId: string, updater: (msg: ChatMessage) => ChatMessage) => {
|
||||
setMessages(prev => prev.map(msg => msg.id === messageId ? updater(msg) : msg));
|
||||
setMessages(prev => {
|
||||
const next = prev.map(msg => msg.id === messageId ? updater(msg) : msg);
|
||||
messagesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMessageAdd = useCallback((message: ChatMessage) => {
|
||||
setMessages(prev => [...prev, message]);
|
||||
setMessages((prev) => {
|
||||
if (message.toolName === TODO_TOOL_NAME && Array.isArray(message.todoSnapshot)) {
|
||||
const preservedMessages = prev.filter((existingMessage) => (
|
||||
existingMessage.toolName !== TODO_TOOL_NAME
|
||||
|| !Array.isArray(existingMessage.todoSnapshot)
|
||||
|| isCompletedTodoSnapshotMessage(existingMessage)
|
||||
));
|
||||
const next = [...preservedMessages, message];
|
||||
messagesRef.current = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
const next = [...prev, message];
|
||||
messagesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMessageRemove = useCallback((messageId: string) => {
|
||||
setMessages(prev => prev.filter(msg => msg.id !== messageId));
|
||||
setMessages(prev => {
|
||||
const next = prev.filter(msg => msg.id !== messageId);
|
||||
messagesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleProcessingChange = useCallback((processing: boolean) => {
|
||||
@@ -170,15 +291,151 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
});
|
||||
|
||||
const clearChatUI = useCallback(async () => {
|
||||
messagesRef.current = [];
|
||||
setMessages([]);
|
||||
setIsFirstMessage(true);
|
||||
setShowHistoryPanel(false);
|
||||
|
||||
const welcomeMessage = await addWelcomeMessage();
|
||||
if (welcomeMessage) {
|
||||
messagesRef.current = [welcomeMessage];
|
||||
setMessages([welcomeMessage]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const buildSavedConversationDocument = useCallback((displayMessages: ChatMessage[]): SavedConversationDocument | null => {
|
||||
const agentState = AgentCore.instance().getAgentState();
|
||||
const fullHistoryMessages = agentState.getFullMessages();
|
||||
if (fullHistoryMessages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const durableTranscript = displayMessages
|
||||
.map(toDurableChatMessage)
|
||||
.filter((message): message is ChatMessage => message !== null);
|
||||
const transcriptToPersist = durableTranscript.length > 0
|
||||
? durableTranscript
|
||||
: fullHistoryMessages
|
||||
.filter((message): message is Message & { role: 'user' | 'assistant' } => (
|
||||
message.role === 'user' || message.role === 'assistant'
|
||||
))
|
||||
.map((message) => ({
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content: message.content ?? '',
|
||||
}));
|
||||
|
||||
return {
|
||||
version: SAVED_CONVERSATION_VERSION,
|
||||
conversationId: agentState.getConversationId(),
|
||||
continuationState: {
|
||||
messages: agentState.getMessages(),
|
||||
todos: agentState.getTodos(),
|
||||
},
|
||||
fullHistory: {
|
||||
messages: fullHistoryMessages,
|
||||
},
|
||||
displayTranscript: transcriptToPersist,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const persistConversationDocument = useCallback(async (displayMessages: ChatMessage[]) => {
|
||||
if (!projectName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = buildSavedConversationDocument(displayMessages);
|
||||
if (!document) {
|
||||
lastPersistedConversationKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const fingerprint = JSON.stringify(document);
|
||||
if (lastPersistedConversationKeyRef.current === fingerprint) {
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = buildConversationMeta(
|
||||
document.conversationId,
|
||||
document.fullHistory.messages,
|
||||
document.displayTranscript,
|
||||
);
|
||||
const storage = KGConversationStorage.getInstance();
|
||||
await storage.initialize();
|
||||
await storage.saveConversation(projectName, document, meta);
|
||||
lastPersistedConversationKeyRef.current = fingerprint;
|
||||
}, [buildSavedConversationDocument, projectName]);
|
||||
|
||||
const loadConversationHistory = useCallback(async () => {
|
||||
if (!projectName) {
|
||||
setConversationHistory([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingHistory(true);
|
||||
try {
|
||||
const storage = KGConversationStorage.getInstance();
|
||||
await storage.initialize();
|
||||
setConversationHistory(await storage.listConversations(projectName));
|
||||
} finally {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
}, [projectName]);
|
||||
|
||||
const handleOpenHistoryPanel = useCallback(async () => {
|
||||
await loadConversationHistory();
|
||||
setShowHistoryPanel(true);
|
||||
}, [loadConversationHistory]);
|
||||
|
||||
const handleRestoreConversation = useCallback(async (conversationId: string) => {
|
||||
await persistConversationDocument(messagesRef.current);
|
||||
|
||||
if (!projectName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = KGConversationStorage.getInstance();
|
||||
await storage.initialize();
|
||||
const savedConversation = await storage.loadConversation(projectName, conversationId);
|
||||
if (!savedConversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
AgentCore.instance().restoreConversation(savedConversation.document);
|
||||
messagesRef.current = savedConversation.document.displayTranscript;
|
||||
setMessages(savedConversation.document.displayTranscript);
|
||||
setInputValue('');
|
||||
setLastUserMessage('');
|
||||
setIsFirstMessage(savedConversation.document.fullHistory.messages.length === 0);
|
||||
setShowHistoryPanel(false);
|
||||
lastPersistedConversationKeyRef.current = JSON.stringify(savedConversation.document);
|
||||
}, [persistConversationDocument, projectName]);
|
||||
|
||||
const handleDeleteConversation = useCallback(async (conversationId: string, title: string) => {
|
||||
const confirmed = await showConfirm(
|
||||
t('chatbox.history.deleteConfirm', { title }),
|
||||
{
|
||||
confirmLabel: t('chatbox.history.delete'),
|
||||
cancelLabel: t('chatbox.history.cancel'),
|
||||
},
|
||||
);
|
||||
if (!confirmed || !projectName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = KGConversationStorage.getInstance();
|
||||
await storage.initialize();
|
||||
await storage.deleteConversation(projectName, conversationId);
|
||||
setConversationHistory((prev) => prev.filter((conversation) => conversation.conversationId !== conversationId));
|
||||
}, [projectName, t]);
|
||||
|
||||
const handleStartNewChat = useCallback(async () => {
|
||||
await persistConversationDocument(messagesRef.current);
|
||||
const { setStatus } = useProjectStore.getState();
|
||||
clearChatHistoryAndUI(setStatus);
|
||||
lastPersistedConversationKeyRef.current = null;
|
||||
}, [persistConversationDocument]);
|
||||
|
||||
// Initialize AgentCore with configured provider and register clear UI callback
|
||||
useEffect(() => {
|
||||
const initializeProvider = async () => {
|
||||
@@ -204,6 +461,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
changedKeys.includes('general.llm_provider') ||
|
||||
changedKeys.includes('general.local_browser.context_length') ||
|
||||
changedKeys.some(k => k.startsWith('general.openai.')) ||
|
||||
changedKeys.some(k => k.startsWith('general.claude_openrouter.')) ||
|
||||
changedKeys.some(k => k.startsWith('general.openai_compatible.'))
|
||||
) {
|
||||
applyProviderFromConfig();
|
||||
@@ -224,6 +482,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
|
||||
const welcomeMessage = await addWelcomeMessage();
|
||||
if (welcomeMessage) {
|
||||
messagesRef.current = [welcomeMessage];
|
||||
setMessages([welcomeMessage]);
|
||||
}
|
||||
})();
|
||||
@@ -236,6 +495,10 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
};
|
||||
}, [clearChatUI]);
|
||||
|
||||
useEffect(() => {
|
||||
lastPersistedConversationKeyRef.current = null;
|
||||
}, [projectName]);
|
||||
|
||||
const handleAbort = () => {
|
||||
const controller = streamProcessor.abortController;
|
||||
if (controller) {
|
||||
@@ -244,23 +507,104 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
const agentCore = AgentCore.instance();
|
||||
const userMessageContent = agentCore.abortCurrentRequest();
|
||||
|
||||
setMessages(prev => prev.slice(0, -2));
|
||||
setMessages(prev => {
|
||||
const next = prev.slice(0, -2);
|
||||
messagesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
setInputValue(userMessageContent || lastUserMessage);
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearCommand = () => {
|
||||
const { setStatus } = useProjectStore.getState();
|
||||
clearChatHistoryAndUI(setStatus);
|
||||
};
|
||||
const runCompactionWithStatus = useCallback(async (
|
||||
trigger: 'manual' | 'auto',
|
||||
focus?: string,
|
||||
): Promise<boolean> => {
|
||||
const agentCore = AgentCore.instance();
|
||||
const statusMessage = createMessage('assistant', 'Compacting Conversation');
|
||||
const progressMessage = createStreamingMessage();
|
||||
let progressTokenCount = 0;
|
||||
|
||||
handleMessageAdd(statusMessage);
|
||||
handleMessageAdd(progressMessage);
|
||||
setIsCompacting(true);
|
||||
|
||||
try {
|
||||
const result = await agentCore.compactConversation({
|
||||
trigger,
|
||||
focus,
|
||||
onProgress: () => {
|
||||
progressTokenCount += 1;
|
||||
handleMessageUpdate(progressMessage.id, (msg) => ({
|
||||
...msg,
|
||||
content: `<span class="processing-wave">Processing...</span>${progressTokenCount > 0 ? ` ${progressTokenCount} tokens received.` : ''} click here to abort.`,
|
||||
tokenCount: progressTokenCount,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
handleMessageUpdate(statusMessage.id, (msg) => ({
|
||||
...msg,
|
||||
content: result.changed ? 'Conversation Compacted' : 'Nothing to Compact Yet',
|
||||
}));
|
||||
handleMessageRemove(progressMessage.id);
|
||||
if (result.changed) {
|
||||
const nextMessages = messages
|
||||
.filter((message) => message.id !== progressMessage.id)
|
||||
.map((message) => message.id === statusMessage.id
|
||||
? { ...message, content: 'Conversation Compacted' }
|
||||
: message);
|
||||
await persistConversationDocument(nextMessages);
|
||||
}
|
||||
return result.changed;
|
||||
} catch (error) {
|
||||
console.error('Conversation compaction failed:', error);
|
||||
handleMessageUpdate(statusMessage.id, (msg) => ({
|
||||
...msg,
|
||||
content: `Compaction failed: ${error instanceof Error ? error.message : 'Unable to compact the conversation.'}`,
|
||||
}));
|
||||
handleMessageRemove(progressMessage.id);
|
||||
return false;
|
||||
} finally {
|
||||
setIsCompacting(false);
|
||||
}
|
||||
}, [handleMessageAdd, handleMessageRemove, handleMessageUpdate, persistConversationDocument]);
|
||||
|
||||
const sendWithCompactionRecovery = useCallback(async (
|
||||
llmInput: string,
|
||||
originalUserMessage: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await streamProcessor.processStream(llmInput, 'USER');
|
||||
} catch (error) {
|
||||
const provider = AgentCore.instance().getLLMProvider();
|
||||
if (provider?.isContextTooLongError?.(error)) {
|
||||
const compacted = await runCompactionWithStatus('auto');
|
||||
if (compacted) {
|
||||
try {
|
||||
await streamProcessor.processStream(llmInput, 'USER');
|
||||
} catch (retryError) {
|
||||
console.error('Retry after compaction failed:', retryError, originalUserMessage);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Failed to process user message:', error, originalUserMessage);
|
||||
}
|
||||
}, [runCompactionWithStatus, streamProcessor]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (inputValue.trim() && !isProcessing) {
|
||||
if (inputValue.trim() && !isProcessing && !isCompacting) {
|
||||
const userMessage = inputValue.trim();
|
||||
setLastUserMessage(userMessage);
|
||||
setInputValue('');
|
||||
|
||||
if (/^\/clear(\s|$)/i.test(userMessage)) {
|
||||
await persistConversationDocument(messagesRef.current);
|
||||
}
|
||||
|
||||
const filterResult = await processUserMessage(userMessage);
|
||||
|
||||
if (filterResult.displayUserMessage) {
|
||||
@@ -273,6 +617,14 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
handleMessageAdd(pseudoMessage);
|
||||
}
|
||||
|
||||
if (filterResult.metadata?.command === 'compact') {
|
||||
await runCompactionWithStatus(
|
||||
'manual',
|
||||
typeof filterResult.metadata.focus === 'string' ? filterResult.metadata.focus : undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filterResult.sendToLLM || !filterResult.finalMessageForLLM) {
|
||||
return;
|
||||
}
|
||||
@@ -280,9 +632,9 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
// Log system prompt only for first message
|
||||
if (isFirstMessage) {
|
||||
try {
|
||||
const provider = AgentCore.instance().getLLMProvider();
|
||||
const agentMode = getEffectiveAgentMode(ConfigManager.instance());
|
||||
const systemPrompt = await SystemPrompts.getSystemPromptWithContext(
|
||||
provider?.getPreferredSystemPromptPath?.(),
|
||||
getSystemPromptPathForAgentMode(agentMode),
|
||||
);
|
||||
console.log('------------ SYSTEM ------------');
|
||||
console.log(systemPrompt);
|
||||
@@ -294,7 +646,13 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
}
|
||||
|
||||
// Process through stream processor — AgentCore handles the full agentic loop internally
|
||||
await streamProcessor.processStream(filterResult.finalMessageForLLM, 'USER');
|
||||
const agentCore = AgentCore.instance();
|
||||
if (await agentCore.shouldCompactBeforeNextTurn(filterResult.finalMessageForLLM)) {
|
||||
await runCompactionWithStatus('auto');
|
||||
}
|
||||
|
||||
await sendWithCompactionRecovery(filterResult.finalMessageForLLM, userMessage);
|
||||
await persistConversationDocument(messagesRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -333,32 +691,46 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
|
||||
// Auto-focus input when processing completes
|
||||
useEffect(() => {
|
||||
if (!isProcessing && textareaRef.current) {
|
||||
if (!isProcessing && !isCompacting && textareaRef.current) {
|
||||
setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 0);
|
||||
}
|
||||
}, [isProcessing]);
|
||||
}, [isCompacting, isProcessing]);
|
||||
|
||||
const localRuntimeMessage = localModelState.runtimeSupport.reason;
|
||||
const hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported;
|
||||
|
||||
return (
|
||||
<div className={`chatbox ${isVisible ? '' : 'is-hidden'}`}>
|
||||
<div className="chatbox-header">
|
||||
<h3>{t('assistant.displayName')}</h3>
|
||||
<div className="chatbox-actions">
|
||||
{isProcessing && (
|
||||
<button
|
||||
type="button"
|
||||
title="Abort"
|
||||
onClick={handleAbort}
|
||||
className="chatbox-action-btn"
|
||||
title={t('chatbox.history.title')}
|
||||
aria-pressed={showHistoryPanel}
|
||||
onClick={() => {
|
||||
if (showHistoryPanel) {
|
||||
setShowHistoryPanel(false);
|
||||
} else {
|
||||
void handleOpenHistoryPanel();
|
||||
}
|
||||
}}
|
||||
className={`chatbox-action-btn chatbox-toggle-btn ${showHistoryPanel ? 'is-active' : ''}`}
|
||||
disabled={isProcessing || isCompacting}
|
||||
>
|
||||
<FaBan />
|
||||
<FaHistory />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t('chatbox.fastForward.title')}
|
||||
aria-pressed={toolFastForwardEnabled}
|
||||
onClick={toggleToolFastForwardEnabled}
|
||||
className={`chatbox-action-btn chatbox-toggle-btn ${toolFastForwardEnabled ? 'is-active' : ''}`}
|
||||
>
|
||||
<FaForward />
|
||||
</button>
|
||||
)}
|
||||
<div className="chatbox-export-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
@@ -384,8 +756,9 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
<button
|
||||
type="button"
|
||||
title="New Chat"
|
||||
onClick={handleClearCommand}
|
||||
onClick={() => { void handleStartNewChat(); }}
|
||||
className="chatbox-action-btn"
|
||||
disabled={isProcessing || isCompacting}
|
||||
>
|
||||
<FaPlus />
|
||||
</button>
|
||||
@@ -431,6 +804,69 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showHistoryPanel ? (
|
||||
<div className="chatbox-history-panel">
|
||||
<div className="chatbox-history-header">
|
||||
<h4>{t('chatbox.history.heading')}</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="chatbox-history-cancel"
|
||||
onClick={() => setShowHistoryPanel(false)}
|
||||
>
|
||||
{t('chatbox.history.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="chatbox-history-list">
|
||||
{isLoadingHistory && (
|
||||
<div className="chatbox-history-empty">{t('chatbox.history.loading')}</div>
|
||||
)}
|
||||
{!isLoadingHistory && conversationHistory.length === 0 && (
|
||||
<div className="chatbox-history-empty">{t('chatbox.history.empty')}</div>
|
||||
)}
|
||||
{!isLoadingHistory && conversationHistory.map((conversation) => (
|
||||
<div
|
||||
key={conversation.conversationId}
|
||||
className="chatbox-history-item"
|
||||
>
|
||||
<div className="chatbox-history-item-toprow">
|
||||
<button
|
||||
type="button"
|
||||
className="chatbox-history-open-btn"
|
||||
onClick={() => { void handleRestoreConversation(conversation.conversationId); }}
|
||||
>
|
||||
<div className="chatbox-history-item-title" title={conversation.title}>{conversation.title}</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="chatbox-history-delete-btn"
|
||||
title={t('chatbox.history.delete')}
|
||||
aria-label={t('chatbox.history.delete')}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void handleDeleteConversation(conversation.conversationId, conversation.title);
|
||||
}}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="chatbox-history-open-btn chatbox-history-open-btn-body"
|
||||
onClick={() => { void handleRestoreConversation(conversation.conversationId); }}
|
||||
>
|
||||
<div className="chatbox-history-item-meta">
|
||||
{formatLocalDateTime(new Date(conversation.lastTurnAt))}
|
||||
</div>
|
||||
{conversation.preview && (
|
||||
<div className="chatbox-history-item-preview">{conversation.preview}</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chatbox-messages">
|
||||
{messages.map((message) => (
|
||||
message.role === 'user' ? (
|
||||
@@ -441,14 +877,24 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
|
||||
content={message.content}
|
||||
isStreaming={message.isStreaming}
|
||||
performanceInfo={message.performanceInfo}
|
||||
toolName={message.toolName}
|
||||
toolSuccess={message.toolSuccess}
|
||||
toolRawResult={message.toolRawResult}
|
||||
toolResultDisplayContent={message.toolResultDisplayContent}
|
||||
toolConfirmation={message.toolConfirmation}
|
||||
toolDenied={message.toolDenied}
|
||||
onToolConfirmationDecision={message.onToolConfirmationDecision}
|
||||
todoSnapshot={message.todoSnapshot}
|
||||
isToolCallMessage={message.isToolCallMessage}
|
||||
onAbort={message.isStreaming ? handleAbort : undefined}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isProcessing && (
|
||||
{!isProcessing && !isCompacting && (
|
||||
<div className="chatbox-input-area">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
|
||||
@@ -58,6 +58,10 @@ const storeState = {
|
||||
addAudioTrack: vi.fn(),
|
||||
projectName: 'Test Project',
|
||||
savedProjectName: 'Test Project',
|
||||
showGlobalTracks: false,
|
||||
setShowGlobalTracks: vi.fn((show: boolean) => {
|
||||
storeState.showGlobalTracks = show;
|
||||
}),
|
||||
requestPianoRollScroll: vi.fn(),
|
||||
mainContentScrollRequest: null,
|
||||
activeTrackAutomationTrackId: null,
|
||||
@@ -82,6 +86,11 @@ const finishTrackCreateDialogClose = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGlobalTracksAndRerender = (rerender: (ui: React.ReactNode) => void) => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
rerender(<MainContent />);
|
||||
};
|
||||
|
||||
vi.mock('../stores/projectStore', () => ({
|
||||
useProjectStore: (selector?: StoreSelector) => (
|
||||
selector ? selector(storeState) : storeState
|
||||
@@ -160,6 +169,7 @@ describe('MainContent', () => {
|
||||
storeState.selectedRegionIds = [];
|
||||
storeState.activeRegionId = null;
|
||||
storeState.showPianoRoll = false;
|
||||
storeState.showGlobalTracks = false;
|
||||
storeState.playheadPosition = 0;
|
||||
storeState.timeSignature = { numerator: 4, denominator: 4 };
|
||||
storeState.clearAllSelections.mockClear();
|
||||
@@ -171,6 +181,7 @@ describe('MainContent', () => {
|
||||
storeState.openSpectrogramViewer.mockClear();
|
||||
storeState.addTrack.mockClear();
|
||||
storeState.addAudioTrack.mockClear();
|
||||
storeState.setShowGlobalTracks.mockClear();
|
||||
executeCommandMock.mockClear();
|
||||
});
|
||||
|
||||
@@ -385,29 +396,35 @@ describe('MainContent', () => {
|
||||
expect(screen.queryByRole('dialog', { name: 'Create New Track' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles the mock global tracks button active state locally', () => {
|
||||
render(<MainContent />);
|
||||
it('toggles the persisted global tracks button active state through the store', () => {
|
||||
const { rerender } = render(<MainContent />);
|
||||
|
||||
const globalTracksButton = screen.getByRole('button', { name: 'Show global tracks' });
|
||||
let globalTracksButton = screen.getByRole('button', { name: 'Show global tracks' });
|
||||
|
||||
expect(globalTracksButton.className).not.toContain('active');
|
||||
|
||||
fireEvent.click(globalTracksButton);
|
||||
expect(storeState.setShowGlobalTracks).toHaveBeenCalledWith(true);
|
||||
rerender(<MainContent />);
|
||||
globalTracksButton = screen.getByRole('button', { name: 'Show global tracks' });
|
||||
expect(globalTracksButton.className).toContain('active');
|
||||
|
||||
fireEvent.click(globalTracksButton);
|
||||
expect(storeState.setShowGlobalTracks).toHaveBeenCalledWith(false);
|
||||
rerender(<MainContent />);
|
||||
globalTracksButton = screen.getByRole('button', { name: 'Show global tracks' });
|
||||
expect(globalTracksButton.className).not.toContain('active');
|
||||
});
|
||||
|
||||
it('renders the four mock global tracks only when toggled on', () => {
|
||||
render(<MainContent />);
|
||||
it('renders the four global tracks only when the persisted toggle is on', () => {
|
||||
const { rerender } = render(<MainContent />);
|
||||
|
||||
expect(screen.queryByText('Marker')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Tempo')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Key Signature')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Chord')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
toggleGlobalTracksAndRerender(rerender);
|
||||
|
||||
expect(screen.getByText('Marker')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tempo')).toBeInTheDocument();
|
||||
@@ -416,7 +433,7 @@ describe('MainContent', () => {
|
||||
|
||||
const globalTracksInfoShell = screen.getByRole('button', { name: 'Add Marker global track item' }).closest('.global-tracks-info-shell') as HTMLElement;
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
toggleGlobalTracksAndRerender(rerender);
|
||||
fireEvent.transitionEnd(globalTracksInfoShell);
|
||||
|
||||
expect(screen.queryByText('Marker')).not.toBeInTheDocument();
|
||||
@@ -427,9 +444,9 @@ describe('MainContent', () => {
|
||||
|
||||
it('routes the tempo and chord global track add buttons through commands', () => {
|
||||
storeState.playheadPosition = 5;
|
||||
render(<MainContent />);
|
||||
const { rerender } = render(<MainContent />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
toggleGlobalTracksAndRerender(rerender);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Tempo global track item' }));
|
||||
expect(executeCommandMock).toHaveBeenCalledTimes(1);
|
||||
@@ -450,10 +467,10 @@ describe('MainContent', () => {
|
||||
]);
|
||||
storeState.globalTracks = globalTracks;
|
||||
|
||||
render(<MainContent />);
|
||||
const { rerender } = render(<MainContent />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'select-midi-region' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
toggleGlobalTracksAndRerender(rerender);
|
||||
fireEvent.click(screen.getByText('Am'));
|
||||
|
||||
expect(storeState.selectedRegionIds).toEqual(['chord-1']);
|
||||
@@ -471,9 +488,9 @@ describe('MainContent', () => {
|
||||
]);
|
||||
storeState.globalTracks = globalTracks;
|
||||
|
||||
render(<MainContent />);
|
||||
const { rerender } = render(<MainContent />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
toggleGlobalTracksAndRerender(rerender);
|
||||
fireEvent.click(screen.getByText('Am'));
|
||||
fireEvent.click(screen.getByText('G'), { shiftKey: true });
|
||||
|
||||
@@ -492,9 +509,9 @@ describe('MainContent', () => {
|
||||
]);
|
||||
storeState.globalTracks = globalTracks;
|
||||
|
||||
render(<MainContent />);
|
||||
const { rerender } = render(<MainContent />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
toggleGlobalTracksAndRerender(rerender);
|
||||
|
||||
const firstChord = screen.getByText('Am');
|
||||
const lastChord = screen.getByText('G');
|
||||
@@ -524,9 +541,9 @@ describe('MainContent', () => {
|
||||
]);
|
||||
storeState.globalTracks = globalTracks;
|
||||
|
||||
render(<MainContent />);
|
||||
const { rerender } = render(<MainContent />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
toggleGlobalTracksAndRerender(rerender);
|
||||
fireEvent.click(screen.getByText('Am'));
|
||||
fireEvent.click(screen.getByText('128 BPM'), { metaKey: true });
|
||||
|
||||
@@ -548,9 +565,9 @@ describe('MainContent', () => {
|
||||
]);
|
||||
storeState.globalTracks = globalTracks;
|
||||
|
||||
render(<MainContent />);
|
||||
const { rerender } = render(<MainContent />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
toggleGlobalTracksAndRerender(rerender);
|
||||
fireEvent.click(screen.getByText('Am'));
|
||||
fireEvent.click(screen.getByText('140 BPM'), { shiftKey: true });
|
||||
|
||||
@@ -566,9 +583,9 @@ describe('MainContent', () => {
|
||||
storeState.globalTracks = globalTracks;
|
||||
storeState.playheadPosition = 3;
|
||||
|
||||
render(<MainContent />);
|
||||
const { rerender } = render(<MainContent />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
toggleGlobalTracksAndRerender(rerender);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Chord global track item' }));
|
||||
|
||||
expect(executeCommandMock).toHaveBeenCalledTimes(1);
|
||||
@@ -586,9 +603,9 @@ describe('MainContent', () => {
|
||||
]);
|
||||
storeState.globalTracks = globalTracks;
|
||||
|
||||
render(<MainContent />);
|
||||
const { rerender } = render(<MainContent />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
toggleGlobalTracksAndRerender(rerender);
|
||||
fireEvent.doubleClick(screen.getByText('Am'));
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab' });
|
||||
|
||||
@@ -605,9 +622,9 @@ describe('MainContent', () => {
|
||||
]);
|
||||
storeState.globalTracks = globalTracks;
|
||||
|
||||
render(<MainContent />);
|
||||
const { rerender } = render(<MainContent />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
toggleGlobalTracksAndRerender(rerender);
|
||||
fireEvent.doubleClick(screen.getByText('Am'));
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab' });
|
||||
|
||||
@@ -626,9 +643,9 @@ describe('MainContent', () => {
|
||||
]);
|
||||
storeState.globalTracks = globalTracks;
|
||||
|
||||
render(<MainContent />);
|
||||
const { rerender } = render(<MainContent />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
toggleGlobalTracksAndRerender(rerender);
|
||||
fireEvent.doubleClick(screen.getByText('G'));
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Tab', shiftKey: true });
|
||||
|
||||
@@ -639,18 +656,18 @@ describe('MainContent', () => {
|
||||
});
|
||||
|
||||
it('routes the signature global track add button through a command', () => {
|
||||
render(<MainContent />);
|
||||
const { rerender } = render(<MainContent />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
toggleGlobalTracksAndRerender(rerender);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Key Signature global track item' }));
|
||||
|
||||
expect(executeCommandMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates new marker regions with a one-bar default length', () => {
|
||||
render(<MainContent />);
|
||||
const { rerender } = render(<MainContent />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
toggleGlobalTracksAndRerender(rerender);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Marker global track item' }));
|
||||
|
||||
expect(executeCommandMock).toHaveBeenCalledTimes(1);
|
||||
@@ -672,13 +689,13 @@ describe('MainContent', () => {
|
||||
return 1;
|
||||
});
|
||||
|
||||
const { container } = render(<MainContent />);
|
||||
const { container, rerender } = render(<MainContent />);
|
||||
const mainContent = container.querySelector('.main-content') as HTMLDivElement;
|
||||
Object.defineProperty(mainContent, 'scrollWidth', { configurable: true, value: 4000 });
|
||||
Object.defineProperty(mainContent, 'clientWidth', { configurable: true, value: 900 });
|
||||
mainContent.scrollLeft = 500;
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show global tracks' }));
|
||||
toggleGlobalTracksAndRerender(rerender);
|
||||
fireEvent.doubleClick(screen.getByText('Am'));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -68,6 +68,8 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
addAudioTrack,
|
||||
projectName,
|
||||
savedProjectName,
|
||||
showGlobalTracks,
|
||||
setShowGlobalTracks,
|
||||
requestPianoRollScroll,
|
||||
mainContentScrollRequest,
|
||||
activeTrackAutomationTrackId,
|
||||
@@ -84,7 +86,6 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
const [draggedTrackIndex, setDraggedTrackIndex] = useState<number | null>(null);
|
||||
const [dragOverTrackIndex, setDragOverTrackIndex] = useState<number | null>(null);
|
||||
const [showCreateTrackModal, setShowCreateTrackModal] = useState(false);
|
||||
const [showGlobalTracksMock, setShowGlobalTracksMock] = useState(false);
|
||||
|
||||
const mainContentRegions = useMainContentRegions({
|
||||
tracks,
|
||||
@@ -252,10 +253,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
setShowCreateTrackModal(true);
|
||||
}, []);
|
||||
|
||||
const handleToggleGlobalTracksMock = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const handleToggleGlobalTracks = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
setShowGlobalTracksMock(previous => !previous);
|
||||
}, []);
|
||||
setShowGlobalTracks(!showGlobalTracks);
|
||||
}, [setShowGlobalTracks, showGlobalTracks]);
|
||||
|
||||
const handleGlobalChordDropToTrack = useCallback(async (draggedRegionId: string, trackIndex: number) => {
|
||||
const targetTrack = tracks[trackIndex];
|
||||
@@ -348,10 +349,10 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`track-header-button${showGlobalTracksMock ? ' active' : ''}`}
|
||||
className={`track-header-button${showGlobalTracks ? ' active' : ''}`}
|
||||
aria-label={t('mainContent.showGlobalTracks')}
|
||||
title={t('mainContent.showGlobalTracks')}
|
||||
onClick={handleToggleGlobalTracksMock}
|
||||
onClick={handleToggleGlobalTracks}
|
||||
>
|
||||
<FaSquareArrowUpRight />
|
||||
</button>
|
||||
@@ -385,7 +386,7 @@ const MainContent: React.FC<MainContentProps> = ({
|
||||
</div>
|
||||
|
||||
<MainContentGlobalTracksSection
|
||||
visible={showGlobalTracksMock}
|
||||
visible={showGlobalTracks}
|
||||
{...mainContentGlobalTracks.sectionProps}
|
||||
chordLaneProps={{
|
||||
...mainContentGlobalTracks.sectionProps.chordLaneProps,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import AssistantMessage from './AssistantMessage';
|
||||
import type { TodoItem } from '../../agent/core/todo';
|
||||
|
||||
describe('AssistantMessage', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -93,4 +95,175 @@ describe('AssistantMessage', () => {
|
||||
expect(codeElement).toBeInTheDocument();
|
||||
expect(codeElement).toHaveTextContent('const value = 1;');
|
||||
});
|
||||
|
||||
it('keeps non-tool-call code blocks rendered without the expander', () => {
|
||||
const { container } = render(
|
||||
<AssistantMessage content={'```json\n{"foo": 1}\n```'} />
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="tool-call-code-block"]')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Click to expand' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses long tool-call code blocks and expands them in place', () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(280);
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 0;
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<div className="chatbox-messages">
|
||||
<AssistantMessage
|
||||
content={'🔧 **Calling tool: add_notes**\n\n```json\n{\n "notes": []\n}\n```'}
|
||||
isToolCallMessage={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const toolCallBlock = screen.getByTestId('tool-call-code-block');
|
||||
const toolCallInner = container.querySelector('.tool-call-code-block-inner') as HTMLDivElement;
|
||||
|
||||
expect(toolCallBlock).toBeInTheDocument();
|
||||
expect(toolCallInner.style.maxHeight).toBe('200px');
|
||||
expect(screen.getByRole('button', { name: 'Click to expand' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Click to expand' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Click to collapse' })).toBeInTheDocument();
|
||||
expect(toolCallInner.style.maxHeight).toBe('280px');
|
||||
});
|
||||
|
||||
it('preserves chat scroll position when expanding a tool-call block', () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(320);
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 0;
|
||||
});
|
||||
|
||||
render(
|
||||
<div className="chatbox-messages">
|
||||
<AssistantMessage
|
||||
content={'🔧 **Calling tool: add_notes**\n\n```json\n{\n "notes": []\n}\n```'}
|
||||
isToolCallMessage={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const scrollContainer = document.querySelector('.chatbox-messages') as HTMLDivElement;
|
||||
scrollContainer.scrollTop = 96;
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Click to expand' }));
|
||||
|
||||
expect(scrollContainer.scrollTop).toBe(96);
|
||||
});
|
||||
|
||||
it('renders compacting and compacted messages as divider banners', () => {
|
||||
const { rerender, container } = render(
|
||||
<AssistantMessage content="Compacting Conversation" />
|
||||
);
|
||||
|
||||
expect(container.querySelector('.message-divider-banner')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Compacting Conversation')).toBeInTheDocument();
|
||||
|
||||
rerender(<AssistantMessage content="Conversation Compacted" />);
|
||||
|
||||
expect(screen.getByLabelText('Conversation Compacted')).toBeInTheDocument();
|
||||
|
||||
rerender(<AssistantMessage content="Nothing to Compact Yet" />);
|
||||
|
||||
expect(screen.getByLabelText('Nothing to Compact Yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a structured todo snapshot card instead of markdown content', () => {
|
||||
const todoSnapshot: TodoItem[] = [
|
||||
{ id: '1', text: 'Inspect melody', status: 'completed', updatedAt: 1 },
|
||||
{ id: '2', text: 'Write harmony', status: 'in_progress', activeText: 'Writing harmony', updatedAt: 2 },
|
||||
];
|
||||
|
||||
render(
|
||||
<AssistantMessage
|
||||
content="fallback content"
|
||||
toolName="update_todo_list"
|
||||
toolSuccess={true}
|
||||
todoSnapshot={todoSnapshot}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText('Agent task checklist')).toBeInTheDocument();
|
||||
expect(screen.getByText('Task Checklist')).toBeInTheDocument();
|
||||
expect(screen.getByText('1/2 completed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Working on: Writing harmony')).toBeInTheDocument();
|
||||
expect(screen.queryByText('fallback content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the add_notes summary instead of the raw tool result text', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
content="✅ **add_notes**\n\n └── Successfully created 81 notes: C4..."
|
||||
toolName="add_notes"
|
||||
toolSuccess={true}
|
||||
toolRawResult="Successfully created 81 notes: C4..."
|
||||
toolResultDisplayContent="Successfully created 81 notes in region **Verse Melody** on track **Lead Vox**, spanning bars 5 to 12."
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('add_notes')).toBeInTheDocument();
|
||||
expect(screen.getByText(/└──/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Successfully created 81 notes in region/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('Verse Melody')).toBeInTheDocument();
|
||||
expect(screen.getByText('Lead Vox')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Successfully created 81 notes: C4/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders generic tool results with the stable shell and raw body by default', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
content="✅ **read_music**\n\n └── raw fallback"
|
||||
toolName="read_music"
|
||||
toolSuccess={true}
|
||||
toolRawResult="C D E F"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('read_music')).toBeInTheDocument();
|
||||
expect(screen.getByText(/└──/)).toBeInTheDocument();
|
||||
expect(screen.getByText('C D E F')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tool confirmation buttons and fires the selected action', () => {
|
||||
const onToolConfirmationDecision = vi.fn();
|
||||
|
||||
render(
|
||||
<AssistantMessage
|
||||
content="confirmation fallback"
|
||||
toolConfirmation={{
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'add_notes',
|
||||
message: 'Allow creating 2 notes in region **Verse Melody** on track **Lead**, spanning bars 5 to 7?',
|
||||
}}
|
||||
onToolConfirmationDecision={onToolConfirmationDecision}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('add_notes')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Allow creating 2 notes/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Always allow' }));
|
||||
expect(onToolConfirmationDecision).toHaveBeenCalledWith('always_allow');
|
||||
});
|
||||
|
||||
it('renders denied tool results with the denied result text', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
content="❌ **add_notes**\n\n └── Execution was denied by the user."
|
||||
toolName="add_notes"
|
||||
toolSuccess={false}
|
||||
toolRawResult="Execution was denied by the user."
|
||||
toolDenied={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('add_notes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Execution was denied by the user.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,133 @@
|
||||
import React, { memo, useEffect, useState } from 'react';
|
||||
import React, { memo, useEffect, useRef, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import { FaCaretDown, FaCaretUp } from 'react-icons/fa';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import type { PerformanceInfo } from '../../agent/llm/StreamingTypes';
|
||||
import type { PerformanceInfo, ToolApprovalDecision } from '../../agent/llm/StreamingTypes';
|
||||
import { summarizeTodoCounts } from '../../agent/core/todo';
|
||||
import type { TodoItem } from '../../agent/core/todo';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
interface AssistantMessageProps {
|
||||
content: string;
|
||||
isStreaming?: boolean;
|
||||
onAbort?: () => void;
|
||||
performanceInfo?: PerformanceInfo;
|
||||
toolName?: string;
|
||||
toolSuccess?: boolean;
|
||||
toolRawResult?: string;
|
||||
toolResultDisplayContent?: string;
|
||||
toolConfirmation?: {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
message: string;
|
||||
};
|
||||
toolDenied?: boolean;
|
||||
onToolConfirmationDecision?: (decision: ToolApprovalDecision) => void;
|
||||
todoSnapshot?: TodoItem[];
|
||||
isToolCallMessage?: boolean;
|
||||
}
|
||||
|
||||
// Memoized code component to prevent SyntaxHighlighter re-renders
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const CodeComponent = memo(({ inline, className, children, ...props }: any) => {
|
||||
const COLLAPSED_TOOL_CALL_HEIGHT_PX = 200;
|
||||
const TOOL_CALL_FADE_HEIGHT_PX = 50;
|
||||
|
||||
interface MarkdownCodeProps {
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ToolCallCodeBlock = memo(({
|
||||
className,
|
||||
children,
|
||||
}: Omit<MarkdownCodeProps, 'inline'>) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isExpandable, setIsExpandable] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [expandedHeight, setExpandedHeight] = useState(COLLAPSED_TOOL_CALL_HEIGHT_PX);
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const codeText = String(children).replace(/\n$/, '');
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const measuredHeight = container.scrollHeight;
|
||||
setExpandedHeight(measuredHeight);
|
||||
setIsExpandable(measuredHeight > COLLAPSED_TOOL_CALL_HEIGHT_PX);
|
||||
}, [codeText]);
|
||||
|
||||
const handleToggleExpanded = () => {
|
||||
const scrollContainer = containerRef.current?.closest('.chatbox-messages') as HTMLDivElement | null;
|
||||
const previousScrollTop = scrollContainer?.scrollTop ?? null;
|
||||
|
||||
setIsExpanded((current) => !current);
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
if (scrollContainer && previousScrollTop !== null) {
|
||||
scrollContainer.scrollTop = previousScrollTop;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const maxHeight = isExpanded ? `${expandedHeight}px` : `${COLLAPSED_TOOL_CALL_HEIGHT_PX}px`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`tool-call-code-block${isExpandable ? ' is-expandable' : ''}${isExpanded ? ' is-expanded' : ''}`}
|
||||
data-testid="tool-call-code-block"
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="tool-call-code-block-inner"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
<SyntaxHighlighter
|
||||
style={vscDarkPlus}
|
||||
language={match?.[1]}
|
||||
PreTag="div"
|
||||
>
|
||||
{codeText}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
{isExpandable && (
|
||||
<button
|
||||
type="button"
|
||||
className="tool-call-code-block-toggle"
|
||||
onClick={handleToggleExpanded}
|
||||
>
|
||||
<span
|
||||
className="tool-call-code-block-fade"
|
||||
aria-hidden="true"
|
||||
style={{ height: `${TOOL_CALL_FADE_HEIGHT_PX}px` }}
|
||||
/>
|
||||
<span className="tool-call-code-block-toggle-content">
|
||||
{isExpanded ? <FaCaretUp aria-hidden="true" /> : <FaCaretDown aria-hidden="true" />}
|
||||
<span>{isExpanded ? 'Click to collapse' : 'Click to expand'}</span>
|
||||
{isExpanded ? <FaCaretUp aria-hidden="true" /> : <FaCaretDown aria-hidden="true" />}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Memoized code component to prevent SyntaxHighlighter re-renders
|
||||
const CodeComponent = memo(({ inline, className, children, isToolCallMessage, ...props }: MarkdownCodeProps & { isToolCallMessage: boolean }) => {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
if (!inline && match && isToolCallMessage) {
|
||||
return (
|
||||
<ToolCallCodeBlock className={className}>
|
||||
{children}
|
||||
</ToolCallCodeBlock>
|
||||
);
|
||||
}
|
||||
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={vscDarkPlus}
|
||||
@@ -44,6 +154,9 @@ const formatTps = (value?: number): string | null => {
|
||||
|
||||
const THINKING_LABEL = 'Thinking...';
|
||||
const PROCESSING_LABEL = 'Processing...';
|
||||
const COMPACTION_IN_PROGRESS_LABEL = 'Compacting Conversation';
|
||||
const COMPACTION_DONE_LABEL = 'Conversation Compacted';
|
||||
const COMPACTION_EMPTY_LABEL = 'Nothing to Compact Yet';
|
||||
|
||||
const formatThinkingDuration = (elapsedSeconds: number): string => {
|
||||
if (elapsedSeconds < 60) {
|
||||
@@ -55,13 +168,34 @@ const formatThinkingDuration = (elapsedSeconds: number): string => {
|
||||
return `Thinking for ${minutes}m ${seconds.toString().padStart(2, '0')}s...`;
|
||||
};
|
||||
|
||||
const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreaming, onAbort, performanceInfo }) => {
|
||||
const AssistantMessage: React.FC<AssistantMessageProps> = ({
|
||||
content,
|
||||
isStreaming,
|
||||
onAbort,
|
||||
performanceInfo,
|
||||
toolName,
|
||||
toolSuccess,
|
||||
toolRawResult,
|
||||
toolResultDisplayContent,
|
||||
toolConfirmation,
|
||||
onToolConfirmationDecision,
|
||||
todoSnapshot,
|
||||
isToolCallMessage = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const prefillTps = formatTps(performanceInfo?.prefillTps);
|
||||
const generationTps = formatTps(performanceInfo?.generationTps);
|
||||
const hasPerformanceInfo = Boolean(prefillTps || generationTps);
|
||||
const [thinkingElapsedSeconds, setThinkingElapsedSeconds] = useState(0);
|
||||
const processingWaveLabels = [THINKING_LABEL, PROCESSING_LABEL];
|
||||
const isThinking = isStreaming && content.includes(`<span class="processing-wave">${THINKING_LABEL}</span>`);
|
||||
const isCompactionBanner = content === COMPACTION_IN_PROGRESS_LABEL
|
||||
|| content === COMPACTION_DONE_LABEL
|
||||
|| content === COMPACTION_EMPTY_LABEL;
|
||||
const isTodoSnapshotCard = toolName === 'update_todo_list' && Array.isArray(todoSnapshot);
|
||||
const isToolConfirmationCard = Boolean(toolConfirmation) && Boolean(onToolConfirmationDecision);
|
||||
const shouldRenderGenericToolResult = Boolean(toolName) && typeof toolSuccess === 'boolean' && !isTodoSnapshotCard;
|
||||
const genericToolDisplayContent = toolResultDisplayContent ?? toolRawResult ?? content;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isThinking) {
|
||||
@@ -83,6 +217,95 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
|
||||
}, [isThinking]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (isTodoSnapshotCard) {
|
||||
const counts = summarizeTodoCounts(todoSnapshot);
|
||||
const activeTodo = todoSnapshot.find(todo => todo.status === 'in_progress') ?? null;
|
||||
|
||||
return (
|
||||
<section className="chatbox-todo-card" aria-label={t('chatbox.todo.ariaLabel')}>
|
||||
<div className="chatbox-todo-card-header">
|
||||
<h4>{t('chatbox.todo.title')}</h4>
|
||||
<span className="chatbox-todo-count">
|
||||
{t('chatbox.todo.count', { completed: counts.completed, total: counts.total })}
|
||||
</span>
|
||||
</div>
|
||||
{activeTodo && (
|
||||
<div className="chatbox-todo-active">
|
||||
{t('chatbox.todo.active', { task: activeTodo.activeText || activeTodo.text })}
|
||||
</div>
|
||||
)}
|
||||
<ul className="chatbox-todo-list">
|
||||
{todoSnapshot.map((todo) => (
|
||||
<li key={todo.id} className={`chatbox-todo-item is-${todo.status}`}>
|
||||
<span className="chatbox-todo-marker" aria-hidden="true">
|
||||
{todo.status === 'completed' ? '✓' : todo.status === 'in_progress' ? '→' : '•'}
|
||||
</span>
|
||||
<span className="chatbox-todo-text">{todo.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (isToolConfirmationCard && toolConfirmation && onToolConfirmationDecision) {
|
||||
return (
|
||||
<div className="message-tool-result">
|
||||
<p className="message-tool-result-title">
|
||||
<span aria-hidden="true">?</span>{' '}
|
||||
<strong>{toolConfirmation.toolName}</strong>
|
||||
</p>
|
||||
<div className="message-tool-summary">
|
||||
<span className="message-tool-summary-prefix" aria-hidden="true">└── </span>
|
||||
<div className="message-tool-summary-content">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
code: (props) => <CodeComponent {...props} isToolCallMessage={false} />,
|
||||
}}
|
||||
>
|
||||
{toolConfirmation.message}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="message-tool-confirmation-actions" aria-label={t('chatbox.tool.confirmation.ariaLabel')}>
|
||||
<button
|
||||
type="button"
|
||||
className="message-tool-confirmation-btn dialog-btn dialog-btn-primary kgone-btn-generate"
|
||||
onClick={() => onToolConfirmationDecision('allow')}
|
||||
>
|
||||
{t('chatbox.tool.confirmation.allow')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="message-tool-confirmation-btn dialog-btn dialog-btn-primary kgone-btn-generate"
|
||||
onClick={() => onToolConfirmationDecision('always_allow')}
|
||||
>
|
||||
{t('chatbox.tool.confirmation.alwaysAllow')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="message-tool-confirmation-btn dialog-btn dialog-btn-cancel kgone-btn-generate"
|
||||
onClick={() => onToolConfirmationDecision('deny')}
|
||||
>
|
||||
{t('chatbox.tool.confirmation.deny')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCompactionBanner) {
|
||||
return (
|
||||
<div className="message-divider-banner" aria-label={content}>
|
||||
<span className="message-divider-banner-line" aria-hidden="true" />
|
||||
<span className="message-divider-banner-label">{content}</span>
|
||||
<span className="message-divider-banner-line" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle special abort link for streaming messages
|
||||
if (isStreaming && onAbort && content.includes('click here to abort')) {
|
||||
const processingWaveMarkup = processingWaveLabels
|
||||
@@ -123,12 +346,37 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRenderGenericToolResult && toolName) {
|
||||
return (
|
||||
<div className="message-tool-result">
|
||||
<p className="message-tool-result-title">
|
||||
<span aria-hidden="true">{toolSuccess ? '✅' : '❌'}</span>{' '}
|
||||
<strong>{toolName}</strong>
|
||||
</p>
|
||||
<div className="message-tool-summary">
|
||||
<span className="message-tool-summary-prefix" aria-hidden="true">└── </span>
|
||||
<div className="message-tool-summary-content">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
code: (props) => <CodeComponent {...props} isToolCallMessage={false} />,
|
||||
}}
|
||||
>
|
||||
{genericToolDisplayContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
code: CodeComponent,
|
||||
code: (props) => <CodeComponent {...props} isToolCallMessage={isToolCallMessage} />,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -14,8 +14,10 @@ const { localSeparatorModelCacheMock } = vi.hoisted(() => ({
|
||||
|
||||
const configState = new Map<string, unknown>([
|
||||
['general.language', 'auto'],
|
||||
['general.agent_mode', 'regular'],
|
||||
['general.llm_provider', 'local_browser'],
|
||||
['general.persist_api_keys_non_localhost', false],
|
||||
['general.auto_compact_threshold_percent', 90],
|
||||
['general.openai.api_key', ''],
|
||||
['general.openai.model', 'gpt-5.4-mini'],
|
||||
['general.openai.flex', false],
|
||||
@@ -106,7 +108,10 @@ describe('GeneralSettings', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
configState.set('general.language', 'auto');
|
||||
configState.set('general.agent_mode', 'regular');
|
||||
configState.set('general.llm_provider', 'local_browser');
|
||||
configState.set('general.local_browser.context_length', 65536);
|
||||
configState.set('general.auto_compact_threshold_percent', 90);
|
||||
configManagerMock.get.mockClear();
|
||||
configManagerMock.set.mockClear();
|
||||
localModelState.isCached = false;
|
||||
@@ -173,6 +178,62 @@ describe('GeneralSettings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders and persists the auto-compact threshold', async () => {
|
||||
renderSettings();
|
||||
|
||||
const select = await screen.findByLabelText(translate('settings.general.autoCompactThreshold.label', undefined, 'en_us'));
|
||||
expect((select as HTMLSelectElement).value).toBe('90');
|
||||
|
||||
fireEvent.change(select, { target: { value: '80' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(configManagerMock.set).toHaveBeenCalledWith('general.auto_compact_threshold_percent', 80);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the auto-compact threshold label from the zh_cn catalog', async () => {
|
||||
renderSettings('zh_cn');
|
||||
|
||||
expect(
|
||||
await screen.findByLabelText(translate('settings.general.autoCompactThreshold.label', undefined, 'zh_cn')),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the music assistant section and initializes the agent mode selector', async () => {
|
||||
configState.set('general.llm_provider', 'openai');
|
||||
configState.set('general.agent_mode', 'efficient');
|
||||
|
||||
renderSettings();
|
||||
|
||||
expect(await screen.findByText('K.G.Studio Music Assistant')).toBeTruthy();
|
||||
const select = screen.getByLabelText('Agent Mode');
|
||||
expect((select as HTMLSelectElement).value).toBe('efficient');
|
||||
});
|
||||
|
||||
it('persists agent mode changes for non-local providers', async () => {
|
||||
configState.set('general.llm_provider', 'openai');
|
||||
|
||||
renderSettings();
|
||||
|
||||
const select = await screen.findByLabelText('Agent Mode');
|
||||
fireEvent.change(select, { target: { value: 'efficient' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(configManagerMock.set).toHaveBeenCalledWith('general.agent_mode', 'efficient');
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the agent mode selector for the local browser provider and shows override help', async () => {
|
||||
configState.set('general.llm_provider', 'local_browser');
|
||||
configState.set('general.agent_mode', 'regular');
|
||||
|
||||
renderSettings();
|
||||
|
||||
const select = await screen.findByLabelText('Agent Mode');
|
||||
expect(select).toBeDisabled();
|
||||
expect(screen.getByText('Local LLM (Browser) always runs the assistant in Efficient Mode.')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders and persists local runtime download URLs', async () => {
|
||||
renderSettings();
|
||||
|
||||
|
||||
@@ -18,6 +18,13 @@ import {
|
||||
LOCAL_SEPARATOR_MODEL_CONFIGS,
|
||||
LOCAL_SEPARATOR_MODEL_IDS,
|
||||
} from '../../../util/local-separator/config';
|
||||
import {
|
||||
DEFAULT_AGENT_MODE,
|
||||
getEffectiveAgentMode,
|
||||
isAgentModeForcedByProvider,
|
||||
normalizeAgentMode,
|
||||
type AgentMode,
|
||||
} from '../../../util/agentMode';
|
||||
|
||||
const LANGUAGE_OPTION_LABELS: Record<Exclude<LanguageSetting, 'auto'>, string> = {
|
||||
en_us: 'English',
|
||||
@@ -29,6 +36,7 @@ const LANGUAGE_OPTION_LABELS: Record<Exclude<LanguageSetting, 'auto'>, string> =
|
||||
const GeneralSettings: React.FC = () => {
|
||||
const { t, setLanguageSetting } = useI18n();
|
||||
const [language, setLanguage] = useState<LanguageSetting>('auto');
|
||||
const [agentMode, setAgentMode] = useState<AgentMode>(DEFAULT_AGENT_MODE);
|
||||
const [llmProvider, setLlmProvider] = useState<string>(LOCAL_LLM_PROVIDER_KEY);
|
||||
const [openaiKey, setOpenaiKey] = useState<string>('');
|
||||
const [openaiModel, setOpenaiModel] = useState<string>('');
|
||||
@@ -41,6 +49,7 @@ const GeneralSettings: React.FC = () => {
|
||||
const [claudeOpenRouterModel, setClaudeOpenRouterModel] = useState<string>('');
|
||||
const [openaiFlex, setOpenaiFlex] = useState<boolean>(false);
|
||||
const [persistApiKeysNonLocalhost, setPersistApiKeysNonLocalhost] = useState<boolean>(false);
|
||||
const [autoCompactThresholdPercent, setAutoCompactThresholdPercent] = useState<80 | 90 | 95>(90);
|
||||
const [compatibleKey, setCompatibleKey] = useState<string>('');
|
||||
const [compatibleBaseUrl, setCompatibleBaseUrl] = useState<string>('');
|
||||
const [compatibleModel, setCompatibleModel] = useState<string>('');
|
||||
@@ -102,11 +111,15 @@ const GeneralSettings: React.FC = () => {
|
||||
}
|
||||
|
||||
setLanguage(((configManager.get('general.language') as LanguageSetting | undefined) ?? 'auto'));
|
||||
setAgentMode(normalizeAgentMode(configManager.get('general.agent_mode')));
|
||||
setLlmProvider((configManager.get('general.llm_provider') as string) || LOCAL_LLM_PROVIDER_KEY);
|
||||
setOpenaiKey((configManager.get('general.openai.api_key') as string) || '');
|
||||
setOpenaiModel((configManager.get('general.openai.model') as string) || '');
|
||||
setOpenaiFlex((configManager.get('general.openai.flex') as boolean) ?? false);
|
||||
setPersistApiKeysNonLocalhost((configManager.get('general.persist_api_keys_non_localhost') as boolean) ?? false);
|
||||
setAutoCompactThresholdPercent(
|
||||
((configManager.get('general.auto_compact_threshold_percent') as 80 | 90 | 95 | undefined) ?? 90),
|
||||
);
|
||||
setGeminiKey((configManager.get('general.gemini.api_key') as string) || '');
|
||||
setGeminiModel((configManager.get('general.gemini.model') as string) || '');
|
||||
setClaudeKey((configManager.get('general.claude.api_key') as string) || '');
|
||||
@@ -165,6 +178,16 @@ const GeneralSettings: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAgentModeChange = async (value: AgentMode) => {
|
||||
setAgentMode(value);
|
||||
try {
|
||||
await configManager.set('general.agent_mode', value);
|
||||
console.log('Agent mode changed to:', value);
|
||||
} catch (error) {
|
||||
console.error('Failed to save agent mode:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLanguageChange = async (value: LanguageSetting) => {
|
||||
setLanguage(value);
|
||||
try {
|
||||
@@ -206,6 +229,18 @@ const GeneralSettings: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoCompactThresholdChange = async (value: string) => {
|
||||
const parsed = Number(value);
|
||||
const normalized: 80 | 90 | 95 = parsed === 80 || parsed === 95 ? parsed : 90;
|
||||
setAutoCompactThresholdPercent(normalized);
|
||||
try {
|
||||
await configManager.set('general.auto_compact_threshold_percent', normalized);
|
||||
console.log('Auto-compact threshold changed to:', normalized);
|
||||
} catch (error) {
|
||||
console.error('Failed to save auto-compact threshold:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGeminiKeyChange = (value: string) => {
|
||||
setGeminiKey(value);
|
||||
debouncedSave('general.gemini.api_key', value);
|
||||
@@ -338,6 +373,8 @@ const GeneralSettings: React.FC = () => {
|
||||
|
||||
const localRuntimeMessage = localModelState.runtimeSupport.reason;
|
||||
const hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported;
|
||||
const isAgentModeOverriddenByLocalProvider = isAgentModeForcedByProvider(llmProvider);
|
||||
const effectiveAgentMode = getEffectiveAgentMode(configManager);
|
||||
|
||||
// NOTE: Gemini and Claude are not supported yet due to CORS issues.
|
||||
return (
|
||||
@@ -407,6 +444,57 @@ const GeneralSettings: React.FC = () => {
|
||||
{t('settings.general.persistKeys.help')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label" htmlFor="general-auto-compact-threshold">
|
||||
{t('settings.general.autoCompactThreshold.label')}
|
||||
</label>
|
||||
<select
|
||||
id="general-auto-compact-threshold"
|
||||
className="settings-select"
|
||||
value={autoCompactThresholdPercent}
|
||||
onChange={(e) => void handleAutoCompactThresholdChange(e.target.value)}
|
||||
>
|
||||
<option value="95">{t('settings.general.autoCompactThreshold.conservative')}</option>
|
||||
<option value="90">{t('settings.general.autoCompactThreshold.standard')}</option>
|
||||
<option value="80">{t('settings.general.autoCompactThreshold.early')}</option>
|
||||
</select>
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
{t('settings.general.autoCompactThreshold.help')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<h4>{t('settings.general.musicAssistant.section')}</h4>
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label" htmlFor="general-agent-mode-select">
|
||||
{t('settings.general.musicAssistant.agentMode.label')}
|
||||
</label>
|
||||
<select
|
||||
id="general-agent-mode-select"
|
||||
className="settings-select"
|
||||
value={agentMode}
|
||||
onChange={(e) => void handleAgentModeChange(e.target.value as AgentMode)}
|
||||
disabled={isAgentModeOverriddenByLocalProvider}
|
||||
>
|
||||
<option value="regular">{t('settings.general.musicAssistant.agentMode.regular')}</option>
|
||||
<option value="efficient">{t('settings.general.musicAssistant.agentMode.efficient')}</option>
|
||||
</select>
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
{isAgentModeOverriddenByLocalProvider
|
||||
? t('settings.general.musicAssistant.agentMode.localOverride')
|
||||
: t('settings.general.musicAssistant.agentMode.help')}
|
||||
</div>
|
||||
{effectiveAgentMode !== agentMode && (
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
{t('settings.general.musicAssistant.agentMode.effectiveValue', {
|
||||
mode: t('settings.general.musicAssistant.agentMode.efficient'),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
|
||||
@@ -100,11 +100,13 @@ export const OPFS_CONSTANTS = {
|
||||
PROJECT_FILE: 'project.json',
|
||||
METADATA_FILE: 'meta.json',
|
||||
MEDIA_DIR: 'media',
|
||||
CONVERSATIONS_DIR: 'conversations',
|
||||
CONVERSATION_FILE: 'conversation.json',
|
||||
};
|
||||
|
||||
export const CONFIG_UPGRADER_CONSTANTS = {
|
||||
VERSION_KEY: '__config_version',
|
||||
CURRENT_VERSION: 4,
|
||||
CURRENT_VERSION: 5,
|
||||
};
|
||||
|
||||
export const URL_CONSTANTS = {
|
||||
|
||||
+10
-8
@@ -341,13 +341,13 @@ export class KGDebugger {
|
||||
* Usage examples in browser console:
|
||||
*
|
||||
* // Single tool call:
|
||||
* await KGStudio.KGDebugger.testToolCall('{"name":"read_music","arguments":{"start":0,"length":8}}')
|
||||
* await KGDebugger.testToolCall('{"name":"read_music","arguments":{"start":0,"length":8}}')
|
||||
*
|
||||
* // Multiple tool calls:
|
||||
* await KGStudio.KGDebugger.testToolCall('[{"name":"remove_notes","arguments":{"start":0,"end_beat":4}},{"name":"add_notes","arguments":{"notes":[{"pitch":"C4","start":0,"length":1}]}}]')
|
||||
* await KGDebugger.testToolCall('[{"name":"remove_notes","arguments":{"start":0,"end_beat":4}},{"name":"add_notes","arguments":{"notes":[{"pitch":"C4","start":0,"length":1}]}}]')
|
||||
*
|
||||
* // Can also pass a JS object directly (no need to stringify):
|
||||
* await KGStudio.KGDebugger.testToolCall({name:"read_music",arguments:{start:0}})
|
||||
* await KGDebugger.testToolCall({name:"read_music",arguments:{start:0}})
|
||||
*
|
||||
* @param input - JSON string, object, or array of tool call(s).
|
||||
* Each tool call should have: { name: string, arguments: object }
|
||||
@@ -430,13 +430,15 @@ export class KGDebugger {
|
||||
console.log(" - Use browser developer tools for best experience");
|
||||
console.log("");
|
||||
console.log("💡 testToolCall examples:");
|
||||
console.log(' await KGStudio.KGDebugger.testToolCall(\'{"name":"read_music","arguments":{"start":0,"length":8}}\')');
|
||||
console.log(' await KGStudio.KGDebugger.testToolCall({name:"add_notes",arguments:{notes:[{pitch:"C4",start:0,length:1}]}})');
|
||||
console.log(' await KGStudio.KGDebugger.testToolCall([{name:"remove_notes",arguments:{start:0,end_beat:4}},{name:"read_music",arguments:{}}])');
|
||||
console.log(' await KGDebugger.testToolCall(\'{"name":"get_user_selected_music_range_and_track","arguments":{}}\')');
|
||||
console.log(' await KGDebugger.testToolCall(\'{"name":"list_all_tracks","arguments":{}}\')');
|
||||
console.log(' await KGDebugger.testToolCall(\'{"name":"read_music","arguments":{"start":0,"length":8}}\')');
|
||||
console.log(' await KGDebugger.testToolCall({name:"add_notes",arguments:{notes:[{pitch:"C4",start:0,length:1}]}})');
|
||||
console.log(' await KGDebugger.testToolCall([{name:"remove_notes",arguments:{start:0,end_beat:4}},{name:"read_music",arguments:{}}])');
|
||||
console.log("");
|
||||
console.log("💡 KGOne input examples:");
|
||||
console.log(' await KGStudio.KGDebugger.inputKGOneCaption("Genre: Eurodance, 90s dance-pop, upbeat electronic...", 30)');
|
||||
console.log(' await KGStudio.KGDebugger.inputKGOneLyrics("[Verse 1]\\nYour lyrics here...\\n\\n[Chorus]\\n...", 30)');
|
||||
console.log(' await KGDebugger.inputKGOneCaption("Genre: Eurodance, 90s dance-pop, upbeat electronic...", 30)');
|
||||
console.log(' await KGDebugger.inputKGOneLyrics("[Verse 1]\\nYour lyrics here...\\n\\n[Chorus]\\n...", 30)');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+28
-2
@@ -54,11 +54,19 @@ export class KGProject {
|
||||
@WithDefault(1)
|
||||
private pianoRollZoom: number = 1;
|
||||
|
||||
@Expose()
|
||||
@WithDefault(false)
|
||||
private showGlobalTracks: boolean = false;
|
||||
|
||||
@Expose()
|
||||
@WithDefault(false)
|
||||
private isMetronomeEnabled: boolean = false;
|
||||
|
||||
@Expose()
|
||||
@WithDefault(0)
|
||||
private projectStructureVersion: number = 0;
|
||||
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 15;
|
||||
public static readonly CURRENT_PROJECT_STRUCTURE_VERSION: number = 16;
|
||||
|
||||
@Expose()
|
||||
@Type(() => KGTrack, {
|
||||
@@ -89,7 +97,7 @@ export class KGProject {
|
||||
private globalTracks: KGGlobalTrack[] = createDefaultGlobalTracks();
|
||||
|
||||
// Constructor
|
||||
constructor(name: string = RESERVED_PROJECT_NAME, maxBars: number = 32, currentBars: number = 0, bpm: number = TIME_CONSTANTS.DEFAULT_BPM, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", isLooping: boolean = false, loopingRange: [number, number] = [0, 0], barWidthMultiplier: number = 2, tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION, pianoRollZoom: number = 1, globalTracks: KGGlobalTrack[] = createDefaultGlobalTracks()) {
|
||||
constructor(name: string = RESERVED_PROJECT_NAME, maxBars: number = 32, currentBars: number = 0, bpm: number = TIME_CONSTANTS.DEFAULT_BPM, timeSignature: TimeSignature = { numerator: 4, denominator: 4 }, keySignature: KeySignature = "C major", selectedMode: string = "ionian", isLooping: boolean = false, loopingRange: [number, number] = [0, 0], barWidthMultiplier: number = 2, tracks: KGTrack[] = [], projectStructureVersion: number = KGProject.CURRENT_PROJECT_STRUCTURE_VERSION, pianoRollZoom: number = 1, globalTracks: KGGlobalTrack[] = createDefaultGlobalTracks(), showGlobalTracks: boolean = false, isMetronomeEnabled: boolean = false) {
|
||||
this.name = name;
|
||||
this.maxBars = maxBars;
|
||||
this.currentBars = currentBars;
|
||||
@@ -104,6 +112,8 @@ export class KGProject {
|
||||
this.projectStructureVersion = projectStructureVersion;
|
||||
this.pianoRollZoom = pianoRollZoom;
|
||||
this.globalTracks = globalTracks;
|
||||
this.showGlobalTracks = showGlobalTracks;
|
||||
this.isMetronomeEnabled = isMetronomeEnabled;
|
||||
}
|
||||
|
||||
// Getters
|
||||
@@ -188,6 +198,22 @@ export class KGProject {
|
||||
return this.projectStructureVersion;
|
||||
}
|
||||
|
||||
public getShowGlobalTracks(): boolean {
|
||||
return this.showGlobalTracks;
|
||||
}
|
||||
|
||||
public setShowGlobalTracks(showGlobalTracks: boolean): void {
|
||||
this.showGlobalTracks = showGlobalTracks;
|
||||
}
|
||||
|
||||
public getIsMetronomeEnabled(): boolean {
|
||||
return this.isMetronomeEnabled;
|
||||
}
|
||||
|
||||
public setIsMetronomeEnabled(isMetronomeEnabled: boolean): void {
|
||||
this.isMetronomeEnabled = isMetronomeEnabled;
|
||||
}
|
||||
|
||||
public getIsLooping(): boolean {
|
||||
return this.isLooping;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { MoveGlobalRegionCommand } from './MoveGlobalRegionCommand';
|
||||
import { ReplaceChordRegionsInRangeCommand } from './ReplaceChordRegionsInRangeCommand';
|
||||
import { ResizeGlobalRegionCommand } from './ResizeGlobalRegionCommand';
|
||||
import { UpdateChordRegionCommand } from './UpdateChordRegionCommand';
|
||||
import { WriteChordProgressionCommand } from './WriteChordProgressionCommand';
|
||||
|
||||
describe('global chord region commands', () => {
|
||||
beforeEach(() => {
|
||||
@@ -147,4 +148,86 @@ describe('global chord region commands', () => {
|
||||
{ symbol: 'F', start: 8, length: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes a chord into the middle of an existing region and preserves both sides', () => {
|
||||
const chordTrack = getChordTrack();
|
||||
chordTrack.setRegions([
|
||||
new KGChordRegion('base', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 8),
|
||||
]);
|
||||
|
||||
const command = new WriteChordProgressionCommand([
|
||||
{ startBeat: 3, length: 2, symbol: 'C' },
|
||||
]);
|
||||
|
||||
command.execute();
|
||||
|
||||
expect((getChordTrack().getRegions() as KGChordRegion[]).map(region => ({
|
||||
symbol: region.getSymbol(),
|
||||
start: region.getStartFromBeat(),
|
||||
length: region.getLength(),
|
||||
}))).toEqual([
|
||||
{ symbol: 'Am', start: 0, length: 3 },
|
||||
{ symbol: 'C', start: 3, length: 2 },
|
||||
{ symbol: 'Am', start: 5, length: 3 },
|
||||
]);
|
||||
|
||||
command.undo();
|
||||
|
||||
expect((getChordTrack().getRegions() as KGChordRegion[]).map(region => ({
|
||||
symbol: region.getSymbol(),
|
||||
start: region.getStartFromBeat(),
|
||||
length: region.getLength(),
|
||||
}))).toEqual([
|
||||
{ symbol: 'Am', start: 0, length: 8 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes multiple non-contiguous chord spans while preserving untouched gaps', () => {
|
||||
const chordTrack = getChordTrack();
|
||||
chordTrack.setRegions([
|
||||
new KGChordRegion('left', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 12),
|
||||
]);
|
||||
|
||||
const command = new WriteChordProgressionCommand([
|
||||
{ startBeat: 2, length: 2, symbol: 'C' },
|
||||
{ startBeat: 8, length: 2, symbol: 'G' },
|
||||
]);
|
||||
|
||||
command.execute();
|
||||
|
||||
expect((getChordTrack().getRegions() as KGChordRegion[]).map(region => ({
|
||||
symbol: region.getSymbol(),
|
||||
start: region.getStartFromBeat(),
|
||||
length: region.getLength(),
|
||||
}))).toEqual([
|
||||
{ symbol: 'Am', start: 0, length: 2 },
|
||||
{ symbol: 'C', start: 2, length: 2 },
|
||||
{ symbol: 'Am', start: 4, length: 4 },
|
||||
{ symbol: 'G', start: 8, length: 2 },
|
||||
{ symbol: 'Am', start: 10, length: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes adjacent chord spans without introducing overlap', () => {
|
||||
const chordTrack = getChordTrack();
|
||||
chordTrack.setRegions([
|
||||
new KGChordRegion('base', chordTrack.getId(), chordTrack.getTrackIndex(), 'Am', 0, 8),
|
||||
]);
|
||||
|
||||
const command = new WriteChordProgressionCommand([
|
||||
{ startBeat: 0, length: 4, symbol: 'C' },
|
||||
{ startBeat: 4, length: 4, symbol: 'F' },
|
||||
]);
|
||||
|
||||
command.execute();
|
||||
|
||||
expect((getChordTrack().getRegions() as KGChordRegion[]).map(region => ({
|
||||
symbol: region.getSymbol(),
|
||||
start: region.getStartFromBeat(),
|
||||
length: region.getLength(),
|
||||
}))).toEqual([
|
||||
{ symbol: 'C', start: 0, length: 4 },
|
||||
{ symbol: 'F', start: 4, length: 4 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { MoveGlobalRegionCommand } from './MoveGlobalRegionCommand';
|
||||
import { ResizeGlobalRegionCommand } from './ResizeGlobalRegionCommand';
|
||||
import { DeleteGlobalRegionCommand } from './DeleteGlobalRegionCommand';
|
||||
import { UpdateGlobalRegionTextCommand } from './UpdateGlobalRegionTextCommand';
|
||||
import { WriteMarkersCommand } from './WriteMarkersCommand';
|
||||
|
||||
describe('global marker region commands', () => {
|
||||
beforeEach(() => {
|
||||
@@ -106,4 +107,63 @@ describe('global marker region commands', () => {
|
||||
expect(markerTrack.getRegions()).toHaveLength(1);
|
||||
expect(markerTrack.getRegions()[0].getId()).toBe('marker');
|
||||
});
|
||||
|
||||
it('writes a marker into the middle of an existing region and preserves both sides', () => {
|
||||
const markerTrack = getMarkerTrack();
|
||||
markerTrack.setRegions([
|
||||
new KGMarkerRegion('base', markerTrack.getId(), markerTrack.getTrackIndex(), 'Intro', 0, 8),
|
||||
]);
|
||||
|
||||
const command = new WriteMarkersCommand([
|
||||
{ startBeat: 3, length: 2, name: 'Hit' },
|
||||
]);
|
||||
|
||||
command.execute();
|
||||
|
||||
expect((getMarkerTrack().getRegions() as KGMarkerRegion[]).map(region => ({
|
||||
name: region.getName(),
|
||||
start: region.getStartFromBeat(),
|
||||
length: region.getLength(),
|
||||
}))).toEqual([
|
||||
{ name: 'Intro', start: 0, length: 3 },
|
||||
{ name: 'Hit', start: 3, length: 2 },
|
||||
{ name: 'Intro', start: 5, length: 3 },
|
||||
]);
|
||||
|
||||
command.undo();
|
||||
|
||||
expect((getMarkerTrack().getRegions() as KGMarkerRegion[]).map(region => ({
|
||||
name: region.getName(),
|
||||
start: region.getStartFromBeat(),
|
||||
length: region.getLength(),
|
||||
}))).toEqual([
|
||||
{ name: 'Intro', start: 0, length: 8 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes multiple non-contiguous marker spans while preserving untouched gaps', () => {
|
||||
const markerTrack = getMarkerTrack();
|
||||
markerTrack.setRegions([
|
||||
new KGMarkerRegion('left', markerTrack.getId(), markerTrack.getTrackIndex(), 'Scene', 0, 12),
|
||||
]);
|
||||
|
||||
const command = new WriteMarkersCommand([
|
||||
{ startBeat: 2, length: 2, name: 'Rise' },
|
||||
{ startBeat: 8, length: 2, name: 'Drop' },
|
||||
]);
|
||||
|
||||
command.execute();
|
||||
|
||||
expect((getMarkerTrack().getRegions() as KGMarkerRegion[]).map(region => ({
|
||||
name: region.getName(),
|
||||
start: region.getStartFromBeat(),
|
||||
length: region.getLength(),
|
||||
}))).toEqual([
|
||||
{ name: 'Scene', start: 0, length: 2 },
|
||||
{ name: 'Rise', start: 2, length: 2 },
|
||||
{ name: 'Scene', start: 4, length: 4 },
|
||||
{ name: 'Drop', start: 8, length: 2 },
|
||||
{ name: 'Scene', start: 10, length: 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { GlobalTrackType } from '../../global-track';
|
||||
import { KGChordRegion } from '../../region/KGChordRegion';
|
||||
import { findGlobalTrackByType } from '../../../util/globalTrackUtil';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
|
||||
export interface WriteChordProgressionEntry {
|
||||
startBeat: number;
|
||||
length: number;
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
function cloneChordRegion(region: KGChordRegion): KGChordRegion {
|
||||
return new KGChordRegion(
|
||||
region.getId(),
|
||||
region.getTrackId(),
|
||||
region.getTrackIndex(),
|
||||
region.getSymbol(),
|
||||
region.getStartFromBeat(),
|
||||
region.getLength(),
|
||||
);
|
||||
}
|
||||
|
||||
function cloneChordRegions(regions: KGChordRegion[]): KGChordRegion[] {
|
||||
return regions.map(cloneChordRegion);
|
||||
}
|
||||
|
||||
export class WriteChordProgressionCommand extends KGCommand {
|
||||
private readonly replacements: WriteChordProgressionEntry[];
|
||||
private originalRegions: KGChordRegion[] | null = null;
|
||||
private nextRegions: KGChordRegion[] | null = null;
|
||||
|
||||
constructor(replacements: WriteChordProgressionEntry[]) {
|
||||
super();
|
||||
this.replacements = replacements.map(replacement => ({
|
||||
startBeat: replacement.startBeat,
|
||||
length: replacement.length,
|
||||
symbol: replacement.symbol,
|
||||
}));
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
|
||||
if (!chordTrack) {
|
||||
throw new Error('Chord global track not found');
|
||||
}
|
||||
|
||||
if (this.nextRegions) {
|
||||
chordTrack.setRegions(cloneChordRegions(this.nextRegions));
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRegions = chordTrack.getRegions()
|
||||
.filter((region): region is KGChordRegion => region instanceof KGChordRegion)
|
||||
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
|
||||
|
||||
const sortedReplacements = [...this.replacements].sort((left, right) => left.startBeat - right.startBeat);
|
||||
this.originalRegions = cloneChordRegions(currentRegions);
|
||||
|
||||
const preservedRegions: KGChordRegion[] = [];
|
||||
for (const region of currentRegions) {
|
||||
const regionStart = region.getStartFromBeat();
|
||||
const regionEnd = regionStart + region.getLength();
|
||||
const overlappingReplacements = sortedReplacements.filter(replacement => (
|
||||
replacement.startBeat < regionEnd
|
||||
&& replacement.startBeat + replacement.length > regionStart
|
||||
));
|
||||
|
||||
if (overlappingReplacements.length === 0) {
|
||||
preservedRegions.push(cloneChordRegion(region));
|
||||
continue;
|
||||
}
|
||||
|
||||
let cursor = regionStart;
|
||||
let fragmentIndex = 0;
|
||||
for (const replacement of overlappingReplacements) {
|
||||
const replacementStart = Math.max(regionStart, replacement.startBeat);
|
||||
const replacementEnd = Math.min(regionEnd, replacement.startBeat + replacement.length);
|
||||
if (replacementStart > cursor) {
|
||||
preservedRegions.push(new KGChordRegion(
|
||||
fragmentIndex === 0 ? region.getId() : generateUniqueId('KGChordRegion'),
|
||||
region.getTrackId(),
|
||||
region.getTrackIndex(),
|
||||
region.getSymbol(),
|
||||
cursor,
|
||||
replacementStart - cursor,
|
||||
));
|
||||
fragmentIndex += 1;
|
||||
}
|
||||
cursor = Math.max(cursor, replacementEnd);
|
||||
}
|
||||
|
||||
if (cursor < regionEnd) {
|
||||
preservedRegions.push(new KGChordRegion(
|
||||
fragmentIndex === 0 ? region.getId() : generateUniqueId('KGChordRegion'),
|
||||
region.getTrackId(),
|
||||
region.getTrackIndex(),
|
||||
region.getSymbol(),
|
||||
cursor,
|
||||
regionEnd - cursor,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
const replacementRegions = sortedReplacements.map(replacement => new KGChordRegion(
|
||||
generateUniqueId('KGChordRegion'),
|
||||
chordTrack.getId(),
|
||||
chordTrack.getTrackIndex(),
|
||||
replacement.symbol,
|
||||
replacement.startBeat,
|
||||
replacement.length,
|
||||
));
|
||||
|
||||
this.nextRegions = [...preservedRegions, ...replacementRegions]
|
||||
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
|
||||
|
||||
chordTrack.setRegions(cloneChordRegions(this.nextRegions));
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.originalRegions) {
|
||||
throw new Error('Cannot undo chord progression write without original regions');
|
||||
}
|
||||
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const chordTrack = findGlobalTrackByType(project, GlobalTrackType.Chord);
|
||||
if (!chordTrack) {
|
||||
throw new Error('Chord global track not found during undo');
|
||||
}
|
||||
|
||||
chordTrack.setRegions(cloneChordRegions(this.originalRegions));
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Write chord progression';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGProject } from '../../KGProject';
|
||||
import { GlobalTrackType } from '../../global-track';
|
||||
import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
|
||||
import { WriteKeySignatureTrackCommand } from './WriteKeySignatureTrackCommand';
|
||||
|
||||
describe('WriteKeySignatureTrackCommand', () => {
|
||||
beforeEach(() => {
|
||||
const project = new KGProject('Signatures', 8, 0, 120);
|
||||
const mockCore = KGCore.instance() as unknown as {
|
||||
getCurrentProject: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
mockCore.getCurrentProject.mockReturnValue(project);
|
||||
});
|
||||
|
||||
const getSignatureTrack = () => {
|
||||
const signatureTrack = KGCore.instance().getCurrentProject().getGlobalTracks()
|
||||
.find(track => track.getType() === GlobalTrackType.Signature);
|
||||
|
||||
if (!signatureTrack) {
|
||||
throw new Error('Signature track missing in test setup');
|
||||
}
|
||||
|
||||
return signatureTrack;
|
||||
};
|
||||
|
||||
it('rebuilds the full signature track from explicit entries', () => {
|
||||
const command = new WriteKeySignatureTrackCommand('C major', [
|
||||
{ startBeat: 8, keySignature: 'G major' },
|
||||
{ startBeat: 16, keySignature: 'D major' },
|
||||
]);
|
||||
command.execute();
|
||||
|
||||
const regions = getSignatureTrack().getRegions() as KGKeySignatureRegion[];
|
||||
expect(regions.map(region => ({
|
||||
keySignature: region.getKeySignature(),
|
||||
startBar: region.getStartBar(),
|
||||
lengthBars: region.getLengthBars(),
|
||||
}))).toEqual([
|
||||
{ keySignature: 'C major', startBar: 0, lengthBars: 2 },
|
||||
{ keySignature: 'G major', startBar: 2, lengthBars: 2 },
|
||||
{ keySignature: 'D major', startBar: 4, lengthBars: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('replaces an existing multi-region track and restores it on undo', () => {
|
||||
const signatureTrack = getSignatureTrack();
|
||||
signatureTrack.setRegions([
|
||||
new KGKeySignatureRegion('existing-1', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'F major', 0, 3, 4),
|
||||
new KGKeySignatureRegion('existing-2', signatureTrack.getId(), signatureTrack.getTrackIndex(), 'Bb major', 3, 5, 4),
|
||||
]);
|
||||
|
||||
const command = new WriteKeySignatureTrackCommand('A minor', [
|
||||
{ startBeat: 12, keySignature: 'E minor' },
|
||||
]);
|
||||
command.execute();
|
||||
|
||||
expect((signatureTrack.getRegions() as KGKeySignatureRegion[]).map(region => region.getKeySignature()))
|
||||
.toEqual(['A minor', 'E minor']);
|
||||
|
||||
command.undo();
|
||||
|
||||
expect((signatureTrack.getRegions() as KGKeySignatureRegion[]).map(region => ({
|
||||
keySignature: region.getKeySignature(),
|
||||
startBar: region.getStartBar(),
|
||||
lengthBars: region.getLengthBars(),
|
||||
}))).toEqual([
|
||||
{ keySignature: 'F major', startBar: 0, lengthBars: 3 },
|
||||
{ keySignature: 'Bb major', startBar: 3, lengthBars: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses only the base key signature when no explicit entries are provided', () => {
|
||||
const command = new WriteKeySignatureTrackCommand('E minor', []);
|
||||
command.execute();
|
||||
|
||||
const regions = getSignatureTrack().getRegions() as KGKeySignatureRegion[];
|
||||
expect(regions).toHaveLength(1);
|
||||
expect(regions[0].getKeySignature()).toBe('E minor');
|
||||
expect(regions[0].getStartBar()).toBe(0);
|
||||
expect(regions[0].getLengthBars()).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import type { KeySignature } from '../../KGProject';
|
||||
import { GlobalTrackType } from '../../global-track';
|
||||
import { KGKeySignatureRegion } from '../../region/KGKeySignatureRegion';
|
||||
import {
|
||||
cloneKeySignatureRegions,
|
||||
findGlobalTrackByType,
|
||||
getSongEndBar,
|
||||
getSortedKeySignatureRegions,
|
||||
} from '../../../util/globalTrackUtil';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
|
||||
export interface WriteKeySignatureEntry {
|
||||
startBeat: number;
|
||||
keySignature: KeySignature;
|
||||
}
|
||||
|
||||
function cloneRegions(regions: KGKeySignatureRegion[], beatsPerBar: number): KGKeySignatureRegion[] {
|
||||
return cloneKeySignatureRegions(regions, beatsPerBar);
|
||||
}
|
||||
|
||||
export class WriteKeySignatureTrackCommand extends KGCommand {
|
||||
private readonly baseKeySignature: KeySignature;
|
||||
private readonly replacements: WriteKeySignatureEntry[];
|
||||
private previousRegions: KGKeySignatureRegion[] | null = null;
|
||||
private nextRegions: KGKeySignatureRegion[] | null = null;
|
||||
|
||||
constructor(baseKeySignature: KeySignature, replacements: WriteKeySignatureEntry[]) {
|
||||
super();
|
||||
this.baseKeySignature = baseKeySignature;
|
||||
this.replacements = replacements.map(replacement => ({
|
||||
startBeat: replacement.startBeat,
|
||||
keySignature: replacement.keySignature,
|
||||
}));
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const beatsPerBar = project.getTimeSignature().numerator;
|
||||
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
|
||||
if (!track) {
|
||||
throw new Error('Signature global track not found');
|
||||
}
|
||||
|
||||
if (this.nextRegions) {
|
||||
track.setRegions(cloneRegions(this.nextRegions, beatsPerBar));
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRegions = getSortedKeySignatureRegions(track, beatsPerBar);
|
||||
this.previousRegions = cloneRegions(currentRegions, beatsPerBar);
|
||||
|
||||
const songEndBar = getSongEndBar(project);
|
||||
if (songEndBar <= 0) {
|
||||
this.nextRegions = [];
|
||||
track.setRegions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedReplacements = this.replacements
|
||||
.map(replacement => ({
|
||||
startBar: Math.floor(replacement.startBeat / beatsPerBar),
|
||||
keySignature: replacement.keySignature,
|
||||
}))
|
||||
.sort((left, right) => left.startBar - right.startBar);
|
||||
|
||||
const nextRegions: KGKeySignatureRegion[] = [];
|
||||
let currentStartBar = 0;
|
||||
let currentKeySignature = this.baseKeySignature;
|
||||
|
||||
for (const replacement of normalizedReplacements) {
|
||||
if (replacement.startBar > currentStartBar) {
|
||||
nextRegions.push(new KGKeySignatureRegion(
|
||||
generateUniqueId('KGKeySignatureRegion'),
|
||||
track.getId(),
|
||||
track.getTrackIndex(),
|
||||
currentKeySignature,
|
||||
currentStartBar,
|
||||
replacement.startBar - currentStartBar,
|
||||
beatsPerBar,
|
||||
));
|
||||
}
|
||||
|
||||
currentStartBar = replacement.startBar;
|
||||
currentKeySignature = replacement.keySignature;
|
||||
}
|
||||
|
||||
if (currentStartBar < songEndBar) {
|
||||
nextRegions.push(new KGKeySignatureRegion(
|
||||
generateUniqueId('KGKeySignatureRegion'),
|
||||
track.getId(),
|
||||
track.getTrackIndex(),
|
||||
currentKeySignature,
|
||||
currentStartBar,
|
||||
songEndBar - currentStartBar,
|
||||
beatsPerBar,
|
||||
));
|
||||
}
|
||||
|
||||
this.nextRegions = nextRegions;
|
||||
track.setRegions(cloneRegions(this.nextRegions, beatsPerBar));
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.previousRegions) {
|
||||
throw new Error('Cannot undo key signature write without original regions');
|
||||
}
|
||||
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const beatsPerBar = project.getTimeSignature().numerator;
|
||||
const track = findGlobalTrackByType(project, GlobalTrackType.Signature);
|
||||
if (!track) {
|
||||
throw new Error('Signature global track not found during undo');
|
||||
}
|
||||
|
||||
track.setRegions(cloneRegions(this.previousRegions, beatsPerBar));
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Write key signature track';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { GlobalTrackType } from '../../global-track';
|
||||
import { KGMarkerRegion } from '../../region/KGMarkerRegion';
|
||||
import { findGlobalTrackByType } from '../../../util/globalTrackUtil';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
|
||||
export interface WriteMarkerEntry {
|
||||
startBeat: number;
|
||||
length: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function cloneMarkerRegion(region: KGMarkerRegion): KGMarkerRegion {
|
||||
return new KGMarkerRegion(
|
||||
region.getId(),
|
||||
region.getTrackId(),
|
||||
region.getTrackIndex(),
|
||||
region.getName(),
|
||||
region.getStartFromBeat(),
|
||||
region.getLength(),
|
||||
);
|
||||
}
|
||||
|
||||
function cloneMarkerRegions(regions: KGMarkerRegion[]): KGMarkerRegion[] {
|
||||
return regions.map(cloneMarkerRegion);
|
||||
}
|
||||
|
||||
export class WriteMarkersCommand extends KGCommand {
|
||||
private readonly replacements: WriteMarkerEntry[];
|
||||
private originalRegions: KGMarkerRegion[] | null = null;
|
||||
private nextRegions: KGMarkerRegion[] | null = null;
|
||||
|
||||
constructor(replacements: WriteMarkerEntry[]) {
|
||||
super();
|
||||
this.replacements = replacements.map(replacement => ({
|
||||
startBeat: replacement.startBeat,
|
||||
length: replacement.length,
|
||||
name: replacement.name,
|
||||
}));
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
|
||||
if (!markerTrack) {
|
||||
throw new Error('Marker global track not found');
|
||||
}
|
||||
|
||||
if (this.nextRegions) {
|
||||
markerTrack.setRegions(cloneMarkerRegions(this.nextRegions));
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRegions = markerTrack.getRegions()
|
||||
.filter((region): region is KGMarkerRegion => region instanceof KGMarkerRegion)
|
||||
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
|
||||
|
||||
const sortedReplacements = [...this.replacements].sort((left, right) => left.startBeat - right.startBeat);
|
||||
this.originalRegions = cloneMarkerRegions(currentRegions);
|
||||
|
||||
const preservedRegions: KGMarkerRegion[] = [];
|
||||
for (const region of currentRegions) {
|
||||
const regionStart = region.getStartFromBeat();
|
||||
const regionEnd = regionStart + region.getLength();
|
||||
const overlappingReplacements = sortedReplacements.filter(replacement => (
|
||||
replacement.startBeat < regionEnd
|
||||
&& replacement.startBeat + replacement.length > regionStart
|
||||
));
|
||||
|
||||
if (overlappingReplacements.length === 0) {
|
||||
preservedRegions.push(cloneMarkerRegion(region));
|
||||
continue;
|
||||
}
|
||||
|
||||
let cursor = regionStart;
|
||||
let fragmentIndex = 0;
|
||||
for (const replacement of overlappingReplacements) {
|
||||
const replacementStart = Math.max(regionStart, replacement.startBeat);
|
||||
const replacementEnd = Math.min(regionEnd, replacement.startBeat + replacement.length);
|
||||
if (replacementStart > cursor) {
|
||||
preservedRegions.push(new KGMarkerRegion(
|
||||
fragmentIndex === 0 ? region.getId() : generateUniqueId('KGMarkerRegion'),
|
||||
region.getTrackId(),
|
||||
region.getTrackIndex(),
|
||||
region.getName(),
|
||||
cursor,
|
||||
replacementStart - cursor,
|
||||
));
|
||||
fragmentIndex += 1;
|
||||
}
|
||||
cursor = Math.max(cursor, replacementEnd);
|
||||
}
|
||||
|
||||
if (cursor < regionEnd) {
|
||||
preservedRegions.push(new KGMarkerRegion(
|
||||
fragmentIndex === 0 ? region.getId() : generateUniqueId('KGMarkerRegion'),
|
||||
region.getTrackId(),
|
||||
region.getTrackIndex(),
|
||||
region.getName(),
|
||||
cursor,
|
||||
regionEnd - cursor,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
const replacementRegions = sortedReplacements.map(replacement => new KGMarkerRegion(
|
||||
generateUniqueId('KGMarkerRegion'),
|
||||
markerTrack.getId(),
|
||||
markerTrack.getTrackIndex(),
|
||||
replacement.name,
|
||||
replacement.startBeat,
|
||||
replacement.length,
|
||||
));
|
||||
|
||||
this.nextRegions = [...preservedRegions, ...replacementRegions]
|
||||
.sort((left, right) => left.getStartFromBeat() - right.getStartFromBeat());
|
||||
|
||||
markerTrack.setRegions(cloneMarkerRegions(this.nextRegions));
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.originalRegions) {
|
||||
throw new Error('Cannot undo marker write without original regions');
|
||||
}
|
||||
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const markerTrack = findGlobalTrackByType(project, GlobalTrackType.Marker);
|
||||
if (!markerTrack) {
|
||||
throw new Error('Marker global track not found during undo');
|
||||
}
|
||||
|
||||
markerTrack.setRegions(cloneMarkerRegions(this.originalRegions));
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Write markers';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { KGProject } from '../../KGProject';
|
||||
import { GlobalTrackType } from '../../global-track';
|
||||
import { KGTempoRegion } from '../../region/KGTempoRegion';
|
||||
import { WriteTempoTrackCommand } from './WriteTempoTrackCommand';
|
||||
|
||||
describe('WriteTempoTrackCommand', () => {
|
||||
beforeEach(() => {
|
||||
const project = new KGProject('Tempo', 8, 0, 120);
|
||||
const mockCore = KGCore.instance() as unknown as {
|
||||
getCurrentProject: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
mockCore.getCurrentProject.mockReturnValue(project);
|
||||
});
|
||||
|
||||
const getTempoTrack = () => {
|
||||
const tempoTrack = KGCore.instance().getCurrentProject().getGlobalTracks()
|
||||
.find(track => track.getType() === GlobalTrackType.Tempo);
|
||||
|
||||
if (!tempoTrack) {
|
||||
throw new Error('Tempo track missing in test setup');
|
||||
}
|
||||
|
||||
return tempoTrack;
|
||||
};
|
||||
|
||||
it('writes base BPM only by clearing explicit tempo regions and updating project BPM', () => {
|
||||
const tempoTrack = getTempoTrack();
|
||||
tempoTrack.setRegions([
|
||||
new KGTempoRegion('existing-1', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 3, 4),
|
||||
new KGTempoRegion('existing-2', tempoTrack.getId(), tempoTrack.getTrackIndex(), 140, 3, 5, 4),
|
||||
]);
|
||||
|
||||
const command = new WriteTempoTrackCommand(96, []);
|
||||
command.execute();
|
||||
|
||||
expect(KGCore.instance().getCurrentProject().getBpm()).toBe(96);
|
||||
expect(tempoTrack.getRegions()).toEqual([]);
|
||||
});
|
||||
|
||||
it('rebuilds explicit tempo regions into a gapless full-song plan', () => {
|
||||
const command = new WriteTempoTrackCommand(100, [
|
||||
{ startBeat: 8, bpm: 120 },
|
||||
{ startBeat: 16, bpm: 140 },
|
||||
]);
|
||||
command.execute();
|
||||
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const regions = getTempoTrack().getRegions() as KGTempoRegion[];
|
||||
|
||||
expect(project.getBpm()).toBe(100);
|
||||
expect(regions.map(region => ({
|
||||
bpm: region.getBpm(),
|
||||
startBar: region.getStartBar(),
|
||||
lengthBars: region.getLengthBars(),
|
||||
}))).toEqual([
|
||||
{ bpm: 100, startBar: 0, lengthBars: 2 },
|
||||
{ bpm: 120, startBar: 2, lengthBars: 2 },
|
||||
{ bpm: 140, startBar: 4, lengthBars: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('restores both project BPM and prior tempo regions on undo', () => {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const tempoTrack = getTempoTrack();
|
||||
tempoTrack.setRegions([
|
||||
new KGTempoRegion('existing-1', tempoTrack.getId(), tempoTrack.getTrackIndex(), 120, 0, 2, 4),
|
||||
new KGTempoRegion('existing-2', tempoTrack.getId(), tempoTrack.getTrackIndex(), 128, 2, 6, 4),
|
||||
]);
|
||||
|
||||
const command = new WriteTempoTrackCommand(88, [
|
||||
{ startBeat: 12, bpm: 144 },
|
||||
]);
|
||||
command.execute();
|
||||
|
||||
expect(project.getBpm()).toBe(88);
|
||||
expect((tempoTrack.getRegions() as KGTempoRegion[]).map(region => region.getBpm())).toEqual([88, 144]);
|
||||
|
||||
command.undo();
|
||||
|
||||
expect(project.getBpm()).toBe(120);
|
||||
expect((tempoTrack.getRegions() as KGTempoRegion[]).map(region => ({
|
||||
bpm: region.getBpm(),
|
||||
startBar: region.getStartBar(),
|
||||
lengthBars: region.getLengthBars(),
|
||||
}))).toEqual([
|
||||
{ bpm: 120, startBar: 0, lengthBars: 2 },
|
||||
{ bpm: 128, startBar: 2, lengthBars: 6 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { KGCommand } from '../KGCommand';
|
||||
import { KGCore } from '../../KGCore';
|
||||
import { GlobalTrackType } from '../../global-track';
|
||||
import { KGTempoRegion } from '../../region/KGTempoRegion';
|
||||
import {
|
||||
cloneTempoRegions,
|
||||
findGlobalTrackByType,
|
||||
getSongEndBar,
|
||||
getSortedTempoRegions,
|
||||
} from '../../../util/globalTrackUtil';
|
||||
import { generateUniqueId } from '../../../util/miscUtil';
|
||||
|
||||
export interface WriteTempoEntry {
|
||||
startBeat: number;
|
||||
bpm: number;
|
||||
}
|
||||
|
||||
function cloneRegions(regions: KGTempoRegion[], beatsPerBar: number): KGTempoRegion[] {
|
||||
return cloneTempoRegions(regions, beatsPerBar);
|
||||
}
|
||||
|
||||
export class WriteTempoTrackCommand extends KGCommand {
|
||||
private readonly baseBpm: number;
|
||||
private readonly replacements: WriteTempoEntry[];
|
||||
private previousRegions: KGTempoRegion[] | null = null;
|
||||
private previousProjectBpm: number | null = null;
|
||||
private nextRegions: KGTempoRegion[] | null = null;
|
||||
|
||||
constructor(baseBpm: number, replacements: WriteTempoEntry[]) {
|
||||
super();
|
||||
this.baseBpm = baseBpm;
|
||||
this.replacements = replacements.map(replacement => ({
|
||||
startBeat: replacement.startBeat,
|
||||
bpm: replacement.bpm,
|
||||
}));
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const beatsPerBar = project.getTimeSignature().numerator;
|
||||
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||
if (!track) {
|
||||
throw new Error('Tempo global track not found');
|
||||
}
|
||||
|
||||
if (this.nextRegions) {
|
||||
project.setBpm(this.baseBpm);
|
||||
track.setRegions(cloneRegions(this.nextRegions, beatsPerBar));
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRegions = getSortedTempoRegions(track, beatsPerBar);
|
||||
this.previousProjectBpm = project.getBpm();
|
||||
this.previousRegions = cloneRegions(currentRegions, beatsPerBar);
|
||||
project.setBpm(this.baseBpm);
|
||||
|
||||
if (this.replacements.length === 0) {
|
||||
this.nextRegions = [];
|
||||
track.setRegions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const songEndBar = getSongEndBar(project);
|
||||
if (songEndBar <= 0) {
|
||||
this.nextRegions = [];
|
||||
track.setRegions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedReplacements = this.replacements
|
||||
.map(replacement => ({
|
||||
startBar: Math.floor(replacement.startBeat / beatsPerBar),
|
||||
bpm: replacement.bpm,
|
||||
}))
|
||||
.sort((left, right) => left.startBar - right.startBar);
|
||||
|
||||
for (let index = 1; index < normalizedReplacements.length; index += 1) {
|
||||
const previous = normalizedReplacements[index - 1];
|
||||
const current = normalizedReplacements[index];
|
||||
if (current.startBar <= previous.startBar) {
|
||||
throw new Error(
|
||||
`Tempo entry ${index + 1} overlaps with or collapses into entry ${index} after bar alignment. Entry ${index} normalizes to bar ${previous.startBar + 1}, and entry ${index + 1} normalizes to bar ${current.startBar + 1}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const nextRegions: KGTempoRegion[] = [];
|
||||
let currentStartBar = 0;
|
||||
let currentBpm = this.baseBpm;
|
||||
|
||||
for (const replacement of normalizedReplacements) {
|
||||
if (replacement.startBar > currentStartBar) {
|
||||
nextRegions.push(new KGTempoRegion(
|
||||
generateUniqueId('KGTempoRegion'),
|
||||
track.getId(),
|
||||
track.getTrackIndex(),
|
||||
currentBpm,
|
||||
currentStartBar,
|
||||
replacement.startBar - currentStartBar,
|
||||
beatsPerBar,
|
||||
));
|
||||
}
|
||||
|
||||
currentStartBar = replacement.startBar;
|
||||
currentBpm = replacement.bpm;
|
||||
}
|
||||
|
||||
if (currentStartBar < songEndBar) {
|
||||
nextRegions.push(new KGTempoRegion(
|
||||
generateUniqueId('KGTempoRegion'),
|
||||
track.getId(),
|
||||
track.getTrackIndex(),
|
||||
currentBpm,
|
||||
currentStartBar,
|
||||
songEndBar - currentStartBar,
|
||||
beatsPerBar,
|
||||
));
|
||||
}
|
||||
|
||||
this.nextRegions = nextRegions;
|
||||
track.setRegions(cloneRegions(this.nextRegions, beatsPerBar));
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.previousRegions || this.previousProjectBpm === null) {
|
||||
throw new Error('Cannot undo tempo write without original state');
|
||||
}
|
||||
|
||||
const project = KGCore.instance().getCurrentProject();
|
||||
const beatsPerBar = project.getTimeSignature().numerator;
|
||||
const track = findGlobalTrackByType(project, GlobalTrackType.Tempo);
|
||||
if (!track) {
|
||||
throw new Error('Tempo global track not found during undo');
|
||||
}
|
||||
|
||||
project.setBpm(this.previousProjectBpm);
|
||||
track.setRegions(cloneRegions(this.previousRegions, beatsPerBar));
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Write tempo track';
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,22 @@ export {
|
||||
ReplaceChordRegionsInRangeCommand,
|
||||
type ChordRegionReplacementData,
|
||||
} from './global-region/ReplaceChordRegionsInRangeCommand';
|
||||
export {
|
||||
WriteChordProgressionCommand,
|
||||
type WriteChordProgressionEntry,
|
||||
} from './global-region/WriteChordProgressionCommand';
|
||||
export {
|
||||
WriteMarkersCommand,
|
||||
type WriteMarkerEntry,
|
||||
} from './global-region/WriteMarkersCommand';
|
||||
export {
|
||||
WriteKeySignatureTrackCommand,
|
||||
type WriteKeySignatureEntry,
|
||||
} from './global-region/WriteKeySignatureTrackCommand';
|
||||
export {
|
||||
WriteTempoTrackCommand,
|
||||
type WriteTempoEntry,
|
||||
} from './global-region/WriteTempoTrackCommand';
|
||||
export { CreateKeySignatureRegionCommand } from './global-region/CreateKeySignatureRegionCommand';
|
||||
export { CreateTempoRegionCommand } from './global-region/CreateTempoRegionCommand';
|
||||
export { MoveGlobalRegionCommand } from './global-region/MoveGlobalRegionCommand';
|
||||
|
||||
@@ -4,6 +4,7 @@ import { upgradeConfigToV1 } from './upgradeConfigToV1';
|
||||
import { upgradeConfigToV2 } from './upgradeConfigToV2';
|
||||
import { upgradeConfigToV3 } from './upgradeConfigToV3';
|
||||
import { upgradeConfigToV4 } from './upgradeConfigToV4';
|
||||
import { upgradeConfigToV5 } from './upgradeConfigToV5';
|
||||
|
||||
/**
|
||||
* KGConfigUpgrader — Orchestrates app-level migrations (e.g., storage backend changes).
|
||||
@@ -48,6 +49,10 @@ export class KGConfigUpgrader {
|
||||
await upgradeConfigToV4();
|
||||
break;
|
||||
}
|
||||
case 5: {
|
||||
await upgradeConfigToV5();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`No config upgrader found for version ${nextVersion}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const getRawMock = vi.fn();
|
||||
const saveRawMock = vi.fn();
|
||||
|
||||
vi.mock('../io/KGConfigStorage', () => ({
|
||||
KGConfigStorage: {
|
||||
getInstance: vi.fn(() => ({
|
||||
getRaw: getRawMock,
|
||||
saveRaw: saveRawMock,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
import { upgradeConfigToV5 } from './upgradeConfigToV5';
|
||||
|
||||
describe('upgradeConfigToV5', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('defaults general.agent_mode to regular when the key is missing', async () => {
|
||||
const config: Record<string, unknown> = {
|
||||
general: {
|
||||
llm_provider: 'openai',
|
||||
},
|
||||
};
|
||||
getRawMock.mockResolvedValue(config);
|
||||
|
||||
await upgradeConfigToV5();
|
||||
|
||||
expect(config.general).toEqual({
|
||||
llm_provider: 'openai',
|
||||
agent_mode: 'regular',
|
||||
});
|
||||
expect(saveRawMock).toHaveBeenCalledWith('userConfig', config);
|
||||
});
|
||||
|
||||
it('preserves an explicit general.agent_mode value', async () => {
|
||||
const config: Record<string, unknown> = {
|
||||
general: {
|
||||
agent_mode: 'efficient',
|
||||
},
|
||||
};
|
||||
getRawMock.mockResolvedValue(config);
|
||||
|
||||
await upgradeConfigToV5();
|
||||
|
||||
expect(saveRawMock).not.toHaveBeenCalled();
|
||||
expect((config.general as Record<string, unknown>).agent_mode).toBe('efficient');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { KGConfigStorage } from '../io/KGConfigStorage';
|
||||
|
||||
const CONFIG_KEY = 'userConfig';
|
||||
|
||||
export async function upgradeConfigToV5(): Promise<void> {
|
||||
const storage = KGConfigStorage.getInstance();
|
||||
const rawConfig = await storage.getRaw(CONFIG_KEY);
|
||||
if (!rawConfig || typeof rawConfig !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = rawConfig as Record<string, unknown>;
|
||||
const general = config.general;
|
||||
|
||||
if (!general || typeof general !== 'object') {
|
||||
config.general = {
|
||||
agent_mode: 'regular',
|
||||
};
|
||||
await storage.saveRaw(CONFIG_KEY, config);
|
||||
return;
|
||||
}
|
||||
|
||||
const generalRecord = general as Record<string, unknown>;
|
||||
if ('agent_mode' in generalRecord) {
|
||||
return;
|
||||
}
|
||||
|
||||
generalRecord.agent_mode = 'regular';
|
||||
await storage.saveRaw(CONFIG_KEY, config);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user