mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-10 15:55:53 +08:00
Compare commits
50 Commits
fix/file-t
...
v1.34.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6a45b3183 | ||
|
|
ce327b6fa9 | ||
|
|
276639099b | ||
|
|
f4f88318c2 | ||
|
|
029d159592 | ||
|
|
7c9ec8fa12 | ||
|
|
1b4d4b7278 | ||
|
|
b1a0afe9e0 | ||
|
|
88eb2009bb | ||
|
|
602e6ad4ac | ||
|
|
4a2453fe32 | ||
|
|
f439a8a3d5 | ||
|
|
23210bc40e | ||
|
|
beae8c6513 | ||
|
|
33a4e72ca4 | ||
|
|
f7c0024fe1 | ||
|
|
ca8fd0ee23 | ||
|
|
b7e6bca2e3 | ||
|
|
84c166c4cb | ||
|
|
231ed04002 | ||
|
|
d70dc077bf | ||
|
|
1faa1a6a00 | ||
|
|
3cd89956ba | ||
|
|
01dbe2a8bf | ||
|
|
f4a1614a0a | ||
|
|
c235b05e1d | ||
|
|
dd77649053 | ||
|
|
af3a28abc7 | ||
|
|
371ff034e4 | ||
|
|
3b4d6885aa | ||
|
|
bc9d2dd830 | ||
|
|
c21a9f4561 | ||
|
|
ed9cdf0114 | ||
|
|
b39997c429 | ||
|
|
d638a8982c | ||
|
|
f238050b85 | ||
|
|
beaa2d2533 | ||
|
|
c90b34108e | ||
|
|
323357384e | ||
|
|
d509aa635b | ||
|
|
2149b8776b | ||
|
|
2b416f2dcb | ||
|
|
bb8db5815c | ||
|
|
b3d0f9037d | ||
|
|
3ec76b5bb1 | ||
|
|
14ddbc7c57 | ||
|
|
ebb0e59e80 | ||
|
|
957f53fb99 | ||
|
|
cdcac182d4 | ||
|
|
94785bfa57 |
68
CHANGELOG.md
68
CHANGELOG.md
@@ -3,6 +3,74 @@
|
|||||||
All notable changes to CloudCLI UI will be documented in this file.
|
All notable changes to CloudCLI UI will be documented in this file.
|
||||||
|
|
||||||
|
|
||||||
|
## [](https://github.com/siteboon/claudecodeui/compare/v1.33.3...vnull) (2026-06-09)
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
* adding Fable 5 in claude code ([ce327b6](https://github.com/siteboon/claudecodeui/commit/ce327b6fa9329aa3e9a3a1da7225ca01d3b06ac5))
|
||||||
|
|
||||||
|
## [1.33.3](https://github.com/siteboon/claudecodeui/compare/v1.33.2...v1.33.3) (2026-06-09)
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
* add file tree upload progress ([c235b05](https://github.com/siteboon/claudecodeui/commit/c235b05e1d3b626667dba4043b685512e3cd3d5d))
|
||||||
|
* signal when chat runs complete ([d70dc07](https://github.com/siteboon/claudecodeui/commit/d70dc077bfbbfcf2ff4fa5514fabf7b4485861fa))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* address notification review feedback ([602e6ad](https://github.com/siteboon/claudecodeui/commit/602e6ad4acba612a7ea66fb3bc7485054f5675ee))
|
||||||
|
* align prism plugin name and id with manifest.json ([ca8fd0e](https://github.com/siteboon/claudecodeui/commit/ca8fd0ee235b6a3210157bd0d9af83024d4a2248))
|
||||||
|
* **chat:** re-anchor initial scroll across lazy content reflow ([33a4e72](https://github.com/siteboon/claudecodeui/commit/33a4e72ca4f84df60aadfc4ff3f3467d6f5ae948))
|
||||||
|
* keep editor toolbar in view on long unwrapped lines ([beae8c6](https://github.com/siteboon/claudecodeui/commit/beae8c6513daa7518b9de40d8bfde3bf08e7bc87))
|
||||||
|
* **sandbox:** prevent server SIGHUP on sbx exec exit ([#792](https://github.com/siteboon/claudecodeui/issues/792)) ([f4a1614](https://github.com/siteboon/claudecodeui/commit/f4a1614a0a4ab4b65e8368d5e4221f015cb7555d)), closes [#791](https://github.com/siteboon/claudecodeui/issues/791)
|
||||||
|
* slash command suggestions trigger at any / in input, not only at start ([#843](https://github.com/siteboon/claudecodeui/issues/843)) ([f7c0024](https://github.com/siteboon/claudecodeui/commit/f7c0024fe15057ad049c71e15e88adb482a4497f))
|
||||||
|
* update naming convention ([3cd8995](https://github.com/siteboon/claudecodeui/commit/3cd89956ba06f0fc3e17d349b0c50baab4012658))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* add prism plugin ([01dbe2a](https://github.com/siteboon/claudecodeui/commit/01dbe2a8bfcb3b265995f01f905b218d5f576f7b))
|
||||||
|
|
||||||
|
## [1.33.2](https://github.com/siteboon/claudecodeui/compare/v1.33.1...v1.33.2) (2026-06-08)
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
* **chat:** open cost modal from token usage ([f238050](https://github.com/siteboon/claudecodeui/commit/f238050b85c3b99a702a8635059735e1a3b3a4f4))
|
||||||
|
* **i18n:** add Traditional Chinese (zh-TW) locale ([#773](https://github.com/siteboon/claudecodeui/issues/773)) ([c21a9f4](https://github.com/siteboon/claudecodeui/commit/c21a9f45610eb1eeb650d8e6cf8650e798f77f6f))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* do not show model description in chat view ([d638a89](https://github.com/siteboon/claudecodeui/commit/d638a8982c7f75b08fc7f65f01d6d54989c790d1))
|
||||||
|
* include Claude cache tokens in usage ([ed9cdf0](https://github.com/siteboon/claudecodeui/commit/ed9cdf01145fa0d063580bb76d30cfa7ee67af86))
|
||||||
|
|
||||||
|
## [1.33.1](https://github.com/siteboon/claudecodeui/compare/v1.33.0...v1.33.1) (2026-06-05)
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
* **chat:** auto-detect text direction for RTL languages ([#729](https://github.com/siteboon/claudecodeui/issues/729)) ([fa9eaf5](https://github.com/siteboon/claudecodeui/commit/fa9eaf5573a6f870a19fb62ab430ffd87c466582))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* file tree concurrency ([#828](https://github.com/siteboon/claudecodeui/issues/828)) ([ebb0e59](https://github.com/siteboon/claudecodeui/commit/ebb0e59e8023c0a8040d168a5adffb7102e80561))
|
||||||
|
* load claude models directly from provider ([cdcac18](https://github.com/siteboon/claudecodeui/commit/cdcac182d458a24908777568979c8e756f94428c))
|
||||||
|
* plugin svg icon sanitization ([#817](https://github.com/siteboon/claudecodeui/issues/817)) ([d9e9df1](https://github.com/siteboon/claudecodeui/commit/d9e9df183f462c88c3b60975eb8254faa9168717))
|
||||||
|
* recognize claude auth token env ([#818](https://github.com/siteboon/claudecodeui/issues/818)) ([43c33d5](https://github.com/siteboon/claudecodeui/commit/43c33d5cb1b41835dfe3bccd450c5a9c2441509b))
|
||||||
|
* redact websocket auth token in logs ([#827](https://github.com/siteboon/claudecodeui/issues/827)) ([14ddbc7](https://github.com/siteboon/claudecodeui/commit/14ddbc7c57a01da9fb65fd87d8588532b11833fa))
|
||||||
|
* remove thinking mode ([#835](https://github.com/siteboon/claudecodeui/issues/835)) ([2149b87](https://github.com/siteboon/claudecodeui/commit/2149b8776b7ebfec0eace413f4fc527ccb2324c0))
|
||||||
|
* **shell:** disconnect and restart buttons ([#831](https://github.com/siteboon/claudecodeui/issues/831)) ([ef2fd48](https://github.com/siteboon/claudecodeui/commit/ef2fd48b46452d4b9e2bf1f5e3c30fafe19f27f2))
|
||||||
|
* show Claude tool result errors ([bb8db58](https://github.com/siteboon/claudecodeui/commit/bb8db5815c2d20ee4fbfa02d14c886a56ef352e0))
|
||||||
|
* **vite:** proxy /plugin-ws WebSocket requests to the backend in dev ([#757](https://github.com/siteboon/claudecodeui/issues/757)) ([96b16b4](https://github.com/siteboon/claudecodeui/commit/96b16b42e4f807d04ec743a5a4117a37a3f5e0d9))
|
||||||
|
* **websocket:** add 30s server-side heartbeat to prevent proxy idle disconnects ([#770](https://github.com/siteboon/claudecodeui/issues/770)) ([2edfef2](https://github.com/siteboon/claudecodeui/commit/2edfef2e3f4271c29ae8670df9dd382a9eef7c3c)), closes [#769](https://github.com/siteboon/claudecodeui/issues/769)
|
||||||
|
* **websocket:** reset unmountedRef on each effect re-run so token refresh reconnects ([#721](https://github.com/siteboon/claudecodeui/issues/721)) ([f082cdc](https://github.com/siteboon/claudecodeui/commit/f082cdc63bd0de90f8b3da1df6071e91ab545831))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* add nginx subpath deployment template ([#820](https://github.com/siteboon/claudecodeui/issues/820)) ([3ec76b5](https://github.com/siteboon/claudecodeui/commit/3ec76b5bb15a13cec41056f4c9b9c425195022fa))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* update Claude fallback models ([94785bf](https://github.com/siteboon/claudecodeui/commit/94785bfa579d1f39a2bee0f9dd0f09fd0243bc79))
|
||||||
|
* update package-lock.json ([c90b341](https://github.com/siteboon/claudecodeui/commit/c90b34108e86a3effdb5c6979ea7b1692d2b9da0))
|
||||||
|
|
||||||
## [](https://github.com/siteboon/claudecodeui/compare/v1.32.0...vnull) (2026-06-01)
|
## [](https://github.com/siteboon/claudecodeui/compare/v1.32.0...vnull) (2026-06-01)
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>Deutsch</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <b>日本語</b> · <a href="./README.tr.md">Türkçe</a></i></div>
|
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <b>日本語</b> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <b>한국어</b> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a> · <b>Türkçe</b></i></div>
|
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <b>Türkçe</b></i></div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>中文</b> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <b>简体中文</b> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
242
README.zh-TW.md
Normal file
242
README.zh-TW.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
|
||||||
|
<h1>Cloud CLI(又名 Claude Code UI)</h1>
|
||||||
|
<p><a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>、<a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>、<a href="https://developers.openai.com/codex">Codex</a> 和 <a href="https://geminicli.com/">Gemini-CLI</a> 的桌面和行動裝置 UI。可在本機或遠端使用,從任何地方查看您的專案與工作階段。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">文件</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug 回報</a> · <a href="CONTRIBUTING.md">貢獻指南</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁️_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
|
||||||
|
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="加入 Discord 社群"></a>
|
||||||
|
<br><br>
|
||||||
|
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.de.md">Deutsch</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">简体中文</a> · <b>繁體中文</b> · <a href="./README.ja.md">日本語</a> · <a href="./README.tr.md">Türkçe</a></i></div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 截圖
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<h3>桌面檢視</h3>
|
||||||
|
<img src="public/screenshots/desktop-main.png" alt="桌面介面" width="400">
|
||||||
|
<br>
|
||||||
|
<em>顯示專案總覽和聊天的主介面</em>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<h3>行動裝置體驗</h3>
|
||||||
|
<img src="public/screenshots/mobile-chat.png" alt="行動裝置介面" width="250">
|
||||||
|
<br>
|
||||||
|
<em>具有觸控導覽的響應式行動裝置設計</em>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" colspan="2">
|
||||||
|
<h3>CLI 選擇</h3>
|
||||||
|
<img src="public/screenshots/cli-selection.png" alt="CLI 選擇" width="400">
|
||||||
|
<br>
|
||||||
|
<em>在 Claude Code、Gemini、Cursor CLI 與 Codex 之間進行選擇</em>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- **響應式設計** — 在桌面、平板和行動裝置上無縫運作,讓您隨時隨地使用 Agents
|
||||||
|
- **互動聊天介面** — 內建聊天 UI,輕鬆與 Agents 交流
|
||||||
|
- **整合 Shell 終端機** — 透過內建 shell 功能直接存取 Agents CLI
|
||||||
|
- **檔案瀏覽器** — 互動式檔案樹,支援語法醒目提示與即時編輯
|
||||||
|
- **Git 瀏覽器** — 檢視、暫存並提交變更,還可切換分支
|
||||||
|
- **工作階段管理** — 恢復對話、管理多個工作階段並追蹤歷史紀錄
|
||||||
|
- **外掛系統** — 透過自訂分頁、後端服務與整合來擴充 CloudCLI。[開始建構 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||||
|
- **TaskMaster AI 整合** *(選用)* — 結合 AI 任務規劃、PRD 分析與工作流程自動化,實現進階專案管理
|
||||||
|
- **模型相容性** — 支援 Claude、GPT、Gemini 模型家族(完整支援列表見 [`shared/modelConstants.js`](shared/modelConstants.js))
|
||||||
|
|
||||||
|
## 快速開始
|
||||||
|
|
||||||
|
### CloudCLI Cloud(推薦)
|
||||||
|
|
||||||
|
無需本機設定即可快速啟動。提供可透過網路瀏覽器、行動應用程式、API 或慣用的 IDE 存取的完全容器化託管開發環境。
|
||||||
|
|
||||||
|
**[立即開始 CloudCLI Cloud](https://cloudcli.ai)**
|
||||||
|
|
||||||
|
### 自架(開源)
|
||||||
|
|
||||||
|
#### npm
|
||||||
|
|
||||||
|
啟動 CloudCLI UI,只需一行 `npx`(需要 Node.js v22+):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @cloudcli-ai/cloudcli
|
||||||
|
```
|
||||||
|
|
||||||
|
或進行全域安裝,便於日常使用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g @cloudcli-ai/cloudcli
|
||||||
|
cloudcli
|
||||||
|
```
|
||||||
|
|
||||||
|
開啟 `http://localhost:3001`,系統會自動發現所有現有工作階段。
|
||||||
|
|
||||||
|
更多設定選項、PM2、遠端伺服器設定等,請參閱 **[文件 →](https://cloudcli.ai/docs)**。
|
||||||
|
|
||||||
|
#### Docker Sandboxes(實驗性)
|
||||||
|
|
||||||
|
在隔離的沙箱中執行代理,具有虛擬機管理程式等級的隔離。預設啟動 Claude Code。需要 [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
|
||||||
|
```
|
||||||
|
|
||||||
|
支援 Claude Code、Codex 和 Gemini CLI。詳情請參閱[沙箱文件](docker/)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 哪個選項更適合你?
|
||||||
|
|
||||||
|
CloudCLI UI 是 CloudCLI Cloud 的開源 UI 層。你可以在本機上自架它,也可以使用提供團隊功能與深入整合的 CloudCLI Cloud。
|
||||||
|
|
||||||
|
| | CloudCLI UI(自架) | CloudCLI Cloud |
|
||||||
|
|---|---|---|
|
||||||
|
| **適合對象** | 需要為本機代理工作階段提供完整 UI 的開發者 | 需要部署在雲端,隨時從任何地方存取代理的團隊與開發者 |
|
||||||
|
| **存取方式** | 透過 `[yourip]:port` 在瀏覽器中存取 | 瀏覽器、任意 IDE、REST API、n8n |
|
||||||
|
| **設定** | `npx @cloudcli-ai/cloudcli` | 無需設定 |
|
||||||
|
| **機器需保持開機嗎** | 是 | 否 |
|
||||||
|
| **行動裝置存取** | 網路內任意瀏覽器 | 任意裝置(原生應用程式即將推出) |
|
||||||
|
| **可用工作階段** | 自動發現 `~/.claude` 中的所有工作階段 | 雲端環境內的工作階段 |
|
||||||
|
| **支援的 Agents** | Claude Code、Cursor CLI、Codex、Gemini CLI | Claude Code、Cursor CLI、Codex、Gemini CLI |
|
||||||
|
| **檔案瀏覽與 Git** | 內建於 UI | 內建於 UI |
|
||||||
|
| **MCP 設定** | UI 管理,與本機 `~/.claude` 設定同步 | UI 管理 |
|
||||||
|
| **IDE 存取** | 本機 IDE | 任何連線到雲端環境的 IDE |
|
||||||
|
| **REST API** | 是 | 是 |
|
||||||
|
| **n8n 節點** | 否 | 是 |
|
||||||
|
| **團隊共享** | 否 | 是 |
|
||||||
|
| **平台費用** | 免費開源 | 起價 $7/月 |
|
||||||
|
|
||||||
|
> 兩種方式都使用你自己的 AI 訂閱(Claude、Cursor 等)— CloudCLI 提供環境,而非 AI。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安全與工具設定
|
||||||
|
|
||||||
|
**🔒 重要提示**:所有 Claude Code 工具預設**停用**,可防止潛在的有害操作自動執行。
|
||||||
|
|
||||||
|
### 啟用工具
|
||||||
|
|
||||||
|
1. **開啟工具設定** — 點擊側邊欄齒輪圖示
|
||||||
|
2. **選擇性啟用** — 僅啟用所需工具
|
||||||
|
3. **套用設定** — 偏好設定儲存在本機
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|
*工具設定介面 — 只啟用你需要的內容*
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
**建議做法**:先啟用基礎工具,再根據需要新增其他工具。隨時可以調整。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 外掛
|
||||||
|
|
||||||
|
CloudCLI 配備外掛系統,允許你新增帶有自訂前端 UI 和選用 Node.js 後端的分頁。在 Settings > Plugins 中直接從 Git 儲存庫安裝外掛,或自行開發。
|
||||||
|
|
||||||
|
### 可用外掛
|
||||||
|
|
||||||
|
| 外掛 | 描述 |
|
||||||
|
|---|---|
|
||||||
|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示目前專案的檔案數、程式碼行數、檔案類型分佈、最大檔案以及最近修改的檔案 |
|
||||||
|
|
||||||
|
### 自行建構
|
||||||
|
|
||||||
|
**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — Fork 該儲存庫以建構自己的外掛。範例包括前端渲染、即時上下文更新和 RPC 通訊。
|
||||||
|
|
||||||
|
**[外掛文件 →](https://cloudcli.ai/docs/plugin-overview)** — 提供外掛 API、清單格式、安全模型等完整指南。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常見問題
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>與 Claude Code Remote Control 有何不同?</summary>
|
||||||
|
|
||||||
|
Claude Code Remote Control 讓你傳送訊息到本機終端機中已經執行的工作階段。該方式要求你的機器保持開機,終端機保持開啟,中斷網路後約 10 分鐘工作階段會逾時。
|
||||||
|
|
||||||
|
CloudCLI UI 與 CloudCLI Cloud 是對 Claude Code 的擴充,而非旁觀 — MCP 伺服器、權限、設定、工作階段與 Claude Code 完全一致。
|
||||||
|
|
||||||
|
- **涵蓋全部工作階段** — CloudCLI UI 會自動掃描 `~/.claude` 資料夾中的每個工作階段。Remote Control 只暴露目前活動的工作階段。
|
||||||
|
- **設定統一** — 在 CloudCLI UI 中修改的 MCP、工具權限等設定會立即寫入 Claude Code。
|
||||||
|
- **支援更多 Agents** — Claude Code、Cursor CLI、Codex、Gemini CLI。
|
||||||
|
- **完整 UI** — 除了聊天介面,還包括檔案瀏覽器、Git 整合、MCP 管理和 Shell 終端機。
|
||||||
|
- **CloudCLI Cloud 持續運作於雲端** — 關閉本機裝置也不會中斷代理執行,無需監控終端機。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>需要額外購買 AI 訂閱嗎?</summary>
|
||||||
|
|
||||||
|
需要。CloudCLI 只提供環境。你仍需自行取得 Claude、Cursor、Codex 或 Gemini 訂閱。CloudCLI Cloud 從 $7/月起提供託管環境。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>能在手機上使用 CloudCLI UI 嗎?</summary>
|
||||||
|
|
||||||
|
可以。自架時,在你的裝置上執行伺服器,然後在網路中的任意瀏覽器開啟 `[yourip]:port`。CloudCLI Cloud 可從任意裝置存取,內建原生應用程式也在開發中。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>UI 中的變更會影響本機 Claude Code 設定嗎?</summary>
|
||||||
|
|
||||||
|
會的。自架模式下,CloudCLI UI 讀取並寫入 Claude Code 使用的 `~/.claude` 設定。透過 UI 新增的 MCP 伺服器會立即在 Claude Code 中可見。
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 社群與支援
|
||||||
|
|
||||||
|
- **[文件](https://cloudcli.ai/docs)** — 安裝、設定、功能與疑難排解指南
|
||||||
|
- **[Discord](https://discord.gg/buxwujPNRE)** — 取得協助並與社群交流
|
||||||
|
- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — 回報 Bug 與建議功能
|
||||||
|
- **[貢獻指南](CONTRIBUTING.md)** — 如何參與專案貢獻
|
||||||
|
|
||||||
|
## 授權條款
|
||||||
|
|
||||||
|
GNU 通用公共授權條款 v3.0 — 詳見 [LICENSE](LICENSE) 檔案。
|
||||||
|
|
||||||
|
該專案為開源軟體,在 GPL v3 授權條款下可自由使用、修改與散布。
|
||||||
|
|
||||||
|
## 致謝
|
||||||
|
|
||||||
|
### 使用技術
|
||||||
|
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** — Anthropic 官方 CLI
|
||||||
|
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** — Cursor 官方 CLI
|
||||||
|
- **[Codex](https://developers.openai.com/codex)** — OpenAI Codex
|
||||||
|
- **[Gemini-CLI](https://geminicli.com/)** — Google Gemini CLI
|
||||||
|
- **[React](https://react.dev/)** — 使用者介面函式庫
|
||||||
|
- **[Vite](https://vitejs.dev/)** — 快速建構工具與開發伺服器
|
||||||
|
- **[Tailwind CSS](https://tailwindcss.com/)** — 實用優先 CSS 框架
|
||||||
|
- **[CodeMirror](https://codemirror.net/)** — 進階程式碼編輯器
|
||||||
|
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(選用)* — AI 驅動的專案管理與任務規劃
|
||||||
|
|
||||||
|
### 贊助商
|
||||||
|
- [Siteboon - AI powered website builder](https://siteboon.ai)
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<strong>為 Claude Code、Cursor 和 Codex 社群精心打造。</strong>
|
||||||
|
</div>
|
||||||
218
docs/nginx-subpath-template.conf
Normal file
218
docs/nginx-subpath-template.conf
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# CloudCLI UI Nginx subpath deployment template.
|
||||||
|
#
|
||||||
|
# Purpose:
|
||||||
|
# Serve CloudCLI UI from a path prefix such as:
|
||||||
|
# http://localhost/ai/
|
||||||
|
# https://example.com/ai/
|
||||||
|
#
|
||||||
|
# CloudCLI itself still runs at the root of its own HTTP server, for example:
|
||||||
|
# http://127.0.0.1:3001/
|
||||||
|
#
|
||||||
|
# Nginx receives public requests under /ai, strips that prefix, and forwards the
|
||||||
|
# remaining path to CloudCLI. For example:
|
||||||
|
# /ai/ -> /
|
||||||
|
# /ai/session/abc -> /session/abc
|
||||||
|
# /ai/assets/index.js -> /assets/index.js
|
||||||
|
#
|
||||||
|
# Important Nginx limitation:
|
||||||
|
# Nginx does not allow variables in `location` matchers or `rewrite` regexes.
|
||||||
|
# The configurable variables below are still useful for proxy/filter values,
|
||||||
|
# but if you change /ai to a different subpath, also update every line marked:
|
||||||
|
# [SUBPATH LITERAL]
|
||||||
|
#
|
||||||
|
# To use a different subpath, replace these literal matchers:
|
||||||
|
# location = /ai
|
||||||
|
# location ^~ /ai/
|
||||||
|
# rewrite ^/ai(?<cloudcli_path>/.*)$ ...
|
||||||
|
#
|
||||||
|
# Recommended deployment shape:
|
||||||
|
# CloudCLI is the only app using /ai, while root paths /api, /ws, and /shell
|
||||||
|
# are also proxied because the current frontend still calls those endpoints
|
||||||
|
# with root-relative URLs.
|
||||||
|
|
||||||
|
worker_processes 1;
|
||||||
|
|
||||||
|
events {
|
||||||
|
# Maximum simultaneous connections handled by each worker process.
|
||||||
|
# The default is enough for local testing and small self-hosted deployments.
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
# WebSocket requests include an Upgrade header. Normal HTTP requests do not.
|
||||||
|
# This map gives us the right Connection header for both cases:
|
||||||
|
# Upgrade present -> "upgrade"
|
||||||
|
# Upgrade absent -> "close"
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
# For HTTPS deployments, replace this with `listen 443 ssl http2;` and
|
||||||
|
# add ssl_certificate / ssl_certificate_key lines.
|
||||||
|
listen 80 default_server;
|
||||||
|
|
||||||
|
# Use your real hostname in production, for example:
|
||||||
|
# server_name cloudcli.example.com;
|
||||||
|
server_name localhost 127.0.0.1;
|
||||||
|
|
||||||
|
# ---- User settings -------------------------------------------------
|
||||||
|
#
|
||||||
|
# Public path prefix where users access CloudCLI.
|
||||||
|
# Do not add a trailing slash.
|
||||||
|
#
|
||||||
|
# This variable can be used in redirects and response rewrites. It
|
||||||
|
# cannot be used in `location` matchers, so update the [SUBPATH LITERAL]
|
||||||
|
# lines too if you change it.
|
||||||
|
set $cloudcli_subpath /ai;
|
||||||
|
|
||||||
|
# Private upstream URL where the CloudCLI server is listening.
|
||||||
|
# For a default local server this is usually http://127.0.0.1:3001.
|
||||||
|
set $cloudcli_upstream http://127.0.0.1:3001;
|
||||||
|
|
||||||
|
# Allow larger file uploads through the code editor/project file APIs.
|
||||||
|
client_max_body_size 200m;
|
||||||
|
|
||||||
|
# Redirect /ai to /ai/ so relative browser URL resolution is stable.
|
||||||
|
# [SUBPATH LITERAL] Change `/ai` if you change $cloudcli_subpath.
|
||||||
|
location = /ai {
|
||||||
|
return 301 $cloudcli_subpath/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main prefixed CloudCLI UI route.
|
||||||
|
#
|
||||||
|
# [SUBPATH LITERAL] Change `/ai/` and the `^/ai` rewrite if you change
|
||||||
|
# $cloudcli_subpath.
|
||||||
|
location ^~ /ai/ {
|
||||||
|
# Strip the public subpath before proxying. CloudCLI expects to see
|
||||||
|
# root paths such as /, /session/:id, /assets/..., /manifest.json.
|
||||||
|
rewrite ^/ai(?<cloudcli_path>/.*)$ $cloudcli_path break;
|
||||||
|
|
||||||
|
# Forward the rewritten request to the private CloudCLI server.
|
||||||
|
proxy_pass $cloudcli_upstream;
|
||||||
|
|
||||||
|
# Use HTTP/1.1 so WebSocket upgrade requests can pass through if a
|
||||||
|
# browser reaches a socket endpoint under the subpath.
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
# Preserve useful request metadata for logs and future app support.
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||||
|
|
||||||
|
# WebSocket upgrade headers. Harmless for normal HTTP requests.
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|
||||||
|
# Long-running agent and terminal sessions can stay open for a long
|
||||||
|
# time, so avoid closing idle proxied connections too aggressively.
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
|
||||||
|
# Disable gzip from the upstream response so sub_filter can inspect
|
||||||
|
# and rewrite HTML/JSON/JS response bodies.
|
||||||
|
proxy_set_header Accept-Encoding "";
|
||||||
|
|
||||||
|
# Rewrite browser-visible root-relative URLs so the runtime can
|
||||||
|
# discover that the app is mounted under the subpath.
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# href="/manifest.json" -> href="/ai/manifest.json"
|
||||||
|
# src="/assets/app.js" -> src="/ai/assets/app.js"
|
||||||
|
#
|
||||||
|
# These rewrites are important for React Router basename detection.
|
||||||
|
sub_filter_once off;
|
||||||
|
sub_filter_types
|
||||||
|
application/json
|
||||||
|
application/manifest+json
|
||||||
|
application/javascript
|
||||||
|
text/javascript;
|
||||||
|
|
||||||
|
sub_filter 'href="/' 'href="$cloudcli_subpath/';
|
||||||
|
sub_filter 'src="/' 'src="$cloudcli_subpath/';
|
||||||
|
|
||||||
|
# The production HTML and JS register the service worker at /sw.js.
|
||||||
|
# Rewrite that registration so the worker is served from /ai/sw.js.
|
||||||
|
sub_filter "register('/sw.js')" "register('$cloudcli_subpath/sw.js')";
|
||||||
|
sub_filter 'register("/sw.js")' 'register("$cloudcli_subpath/sw.js")';
|
||||||
|
|
||||||
|
# The manifest and service worker contain root-relative paths too.
|
||||||
|
# Rewriting them keeps PWA metadata and cached manifest requests
|
||||||
|
# under the same public subpath.
|
||||||
|
sub_filter '"start_url": "/"' '"start_url": "$cloudcli_subpath/"';
|
||||||
|
sub_filter '"scope": "/"' '"scope": "$cloudcli_subpath/"';
|
||||||
|
sub_filter '"src": "/' '"src": "$cloudcli_subpath/';
|
||||||
|
sub_filter "'/manifest.json'" "'$cloudcli_subpath/manifest.json'";
|
||||||
|
sub_filter '"/manifest.json"' '"$cloudcli_subpath/manifest.json"';
|
||||||
|
}
|
||||||
|
|
||||||
|
# Root API proxy.
|
||||||
|
#
|
||||||
|
# The current CloudCLI frontend calls APIs with root-relative URLs such
|
||||||
|
# as /api/auth/login. Keep this location unless the frontend becomes
|
||||||
|
# fully prefix-aware for API requests.
|
||||||
|
location ^~ /api/ {
|
||||||
|
proxy_pass $cloudcli_upstream;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||||
|
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main app WebSocket proxy.
|
||||||
|
#
|
||||||
|
# The frontend opens /ws for realtime chat/session/task updates.
|
||||||
|
location /ws {
|
||||||
|
proxy_pass $cloudcli_upstream;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||||
|
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Shell WebSocket proxy.
|
||||||
|
#
|
||||||
|
# The browser terminal uses /shell. It requires the same WebSocket
|
||||||
|
# upgrade handling as /ws.
|
||||||
|
location /shell {
|
||||||
|
proxy_pass $cloudcli_upstream;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||||
|
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional health endpoint proxy used by the frontend version checker.
|
||||||
|
location = /health {
|
||||||
|
proxy_pass $cloudcli_upstream;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Prefix $cloudcli_subpath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
787
package-lock.json
generated
787
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.33.0",
|
"version": "1.34.0",
|
||||||
"description": "A web-based UI for Claude Code CLI",
|
"description": "A web-based UI for Claude Code CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist-server/server/index.js",
|
"main": "dist-server/server/index.js",
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ export const CLAUDE_MODELS = {
|
|||||||
{
|
{
|
||||||
value: "default",
|
value: "default",
|
||||||
label: "Default (recommended)",
|
label: "Default (recommended)",
|
||||||
description: "Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok",
|
description:
|
||||||
|
"Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "fable",
|
||||||
|
label: "Fable",
|
||||||
|
description:
|
||||||
|
"Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "sonnet",
|
value: "sonnet",
|
||||||
@@ -23,6 +30,12 @@ export const CLAUDE_MODELS = {
|
|||||||
label: "Sonnet (1M context)",
|
label: "Sonnet (1M context)",
|
||||||
description: "Sonnet 4.6 for long sessions · $3/$15 per Mtok",
|
description: "Sonnet 4.6 for long sessions · $3/$15 per Mtok",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "opus[1m]",
|
||||||
|
label: "Opus 4.8 (1M context)",
|
||||||
|
description:
|
||||||
|
"Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "haiku",
|
value: "haiku",
|
||||||
label: "Haiku",
|
label: "Haiku",
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ function mapCliOptionsToSDK(options = {}) {
|
|||||||
sdkOptions.disallowedTools = settings.disallowedTools || [];
|
sdkOptions.disallowedTools = settings.disallowedTools || [];
|
||||||
|
|
||||||
// Map model (default to sonnet)
|
// Map model (default to sonnet)
|
||||||
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
|
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m], fable
|
||||||
sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT;
|
sdkOptions.model = options.model || CLAUDE_FALLBACK_MODELS.DEFAULT;
|
||||||
// Model logged at query start below
|
// Model logged at query start below
|
||||||
|
|
||||||
@@ -304,7 +304,11 @@ function extractTokenBudget(sdkMessage) {
|
|||||||
|
|
||||||
const messageUsage = sdkMessage.message?.usage || sdkMessage.usage;
|
const messageUsage = sdkMessage.message?.usage || sdkMessage.usage;
|
||||||
if (messageUsage && typeof messageUsage === 'object') {
|
if (messageUsage && typeof messageUsage === 'object') {
|
||||||
const inputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
|
const directInputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
|
||||||
|
const cacheCreationTokens = readNumber(messageUsage.cache_creation_input_tokens ?? messageUsage.cacheCreationInputTokens ?? messageUsage.cacheCreationTokens);
|
||||||
|
const cacheReadTokens = readNumber(messageUsage.cache_read_input_tokens ?? messageUsage.cacheReadInputTokens ?? messageUsage.cacheReadTokens);
|
||||||
|
const cacheTokens = cacheCreationTokens + cacheReadTokens;
|
||||||
|
const inputTokens = directInputTokens + cacheTokens;
|
||||||
const outputTokens = readNumber(messageUsage.output_tokens ?? messageUsage.outputTokens);
|
const outputTokens = readNumber(messageUsage.output_tokens ?? messageUsage.outputTokens);
|
||||||
const totalUsed = inputTokens + outputTokens;
|
const totalUsed = inputTokens + outputTokens;
|
||||||
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
|
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
|
||||||
@@ -314,6 +318,9 @@ function extractTokenBudget(sdkMessage) {
|
|||||||
total: contextWindow,
|
total: contextWindow,
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
|
cacheReadTokens,
|
||||||
|
cacheCreationTokens,
|
||||||
|
cacheTokens,
|
||||||
breakdown: {
|
breakdown: {
|
||||||
input: inputTokens,
|
input: inputTokens,
|
||||||
output: outputTokens,
|
output: outputTokens,
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ async function sandboxCommand(args) {
|
|||||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||||
|
|
||||||
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
|
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
|
||||||
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
|
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
|
||||||
|
|
||||||
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
|
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
|
||||||
try {
|
try {
|
||||||
@@ -554,7 +554,7 @@ async function sandboxCommand(args) {
|
|||||||
|
|
||||||
// Step 3: Start CloudCLI inside the sandbox
|
// Step 3: Start CloudCLI inside the sandbox
|
||||||
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
|
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
|
||||||
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
|
sbx(['exec', opts.name, 'bash', '-c', 'nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 & disown']);
|
||||||
|
|
||||||
// Step 4: Forward port
|
// Step 4: Forward port
|
||||||
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
|
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
|
||||||
|
|||||||
@@ -84,9 +84,17 @@ const __dirname = getModuleDir(import.meta.url);
|
|||||||
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
|
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
|
||||||
const APP_ROOT = findAppRoot(__dirname);
|
const APP_ROOT = findAppRoot(__dirname);
|
||||||
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
|
const installMode = fs.existsSync(path.join(APP_ROOT, '.git')) ? 'git' : 'npm';
|
||||||
|
const MAX_FILE_UPLOAD_SIZE_MB = 200;
|
||||||
|
const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
|
||||||
|
const MAX_FILE_UPLOAD_COUNT = 20;
|
||||||
|
|
||||||
console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
|
console.log('SERVER_PORT from env:', process.env.SERVER_PORT);
|
||||||
|
|
||||||
|
function readUsageNumber(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
|
|
||||||
@@ -892,27 +900,27 @@ const uploadFilesHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 50 * 1024 * 1024, // 50MB limit
|
fileSize: MAX_FILE_UPLOAD_SIZE_BYTES,
|
||||||
files: 20 // Max 20 files at once
|
files: MAX_FILE_UPLOAD_COUNT
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use multer middleware
|
// Use multer middleware
|
||||||
uploadMiddleware.array('files', 20)(req, res, async (err) => {
|
uploadMiddleware.array('files', MAX_FILE_UPLOAD_COUNT)(req, res, async (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Multer error:', err);
|
console.error('Multer error:', err);
|
||||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||||
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
|
return res.status(400).json({ error: `File too large. Maximum size is ${MAX_FILE_UPLOAD_SIZE_MB}MB.` });
|
||||||
}
|
}
|
||||||
if (err.code === 'LIMIT_FILE_COUNT') {
|
if (err.code === 'LIMIT_FILE_COUNT') {
|
||||||
return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });
|
return res.status(400).json({ error: `Too many files. Maximum is ${MAX_FILE_UPLOAD_COUNT} files.` });
|
||||||
}
|
}
|
||||||
return res.status(500).json({ error: err.message });
|
return res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { projectId } = req.params;
|
const { projectId } = req.params;
|
||||||
const { targetPath, relativePaths } = req.body;
|
const { targetPath, relativePaths, requestedFileCount: requestedFileCountRaw } = req.body;
|
||||||
|
|
||||||
// Parse relative paths if provided (for folder uploads)
|
// Parse relative paths if provided (for folder uploads)
|
||||||
let filePaths = [];
|
let filePaths = [];
|
||||||
@@ -936,6 +944,11 @@ const uploadFilesHandler = async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'No files provided' });
|
return res.status(400).json({ error: 'No files provided' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parsedRequestedFileCount = Number.parseInt(requestedFileCountRaw, 10);
|
||||||
|
const requestedFileCount = Number.isFinite(parsedRequestedFileCount) && parsedRequestedFileCount > 0
|
||||||
|
? parsedRequestedFileCount
|
||||||
|
: req.files.length;
|
||||||
|
|
||||||
// Resolve the project directory through the DB using the new projectId.
|
// Resolve the project directory through the DB using the new projectId.
|
||||||
const projectRoot = await projectsDb.getProjectPathById(projectId);
|
const projectRoot = await projectsDb.getProjectPathById(projectId);
|
||||||
if (!projectRoot) {
|
if (!projectRoot) {
|
||||||
@@ -1014,8 +1027,10 @@ const uploadFilesHandler = async (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
files: uploadedFiles,
|
files: uploadedFiles,
|
||||||
|
uploadedCount: uploadedFiles.length,
|
||||||
|
requestedFileCount,
|
||||||
targetPath: resolvedTargetDir,
|
targetPath: resolvedTargetDir,
|
||||||
message: `Uploaded ${uploadedFiles.length} file(s) successfully`
|
message: `Uploaded ${uploadedFiles.length} ${uploadedFiles.length === 1 ? 'file' : 'files'} successfully`
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading files:', error);
|
console.error('Error uploading files:', error);
|
||||||
@@ -1386,6 +1401,8 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
||||||
let inputTokens = 0;
|
let inputTokens = 0;
|
||||||
let outputTokens = 0;
|
let outputTokens = 0;
|
||||||
|
let cacheReadTokens = 0;
|
||||||
|
let cacheCreationTokens = 0;
|
||||||
|
|
||||||
// Find the latest assistant message with usage data (scan from end)
|
// Find the latest assistant message with usage data (scan from end)
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
@@ -1397,8 +1414,11 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
const usage = entry.message.usage;
|
const usage = entry.message.usage;
|
||||||
|
|
||||||
// Use token counts from latest assistant message only
|
// Use token counts from latest assistant message only
|
||||||
inputTokens = usage.input_tokens || 0;
|
const directInputTokens = readUsageNumber(usage.input_tokens ?? usage.inputTokens);
|
||||||
outputTokens = usage.output_tokens || 0;
|
cacheReadTokens = readUsageNumber(usage.cache_read_input_tokens ?? usage.cacheReadInputTokens ?? usage.cacheReadTokens);
|
||||||
|
cacheCreationTokens = readUsageNumber(usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens ?? usage.cacheCreationTokens);
|
||||||
|
inputTokens = directInputTokens + cacheReadTokens + cacheCreationTokens;
|
||||||
|
outputTokens = readUsageNumber(usage.output_tokens ?? usage.outputTokens);
|
||||||
|
|
||||||
break; // Stop after finding the latest assistant message
|
break; // Stop after finding the latest assistant message
|
||||||
}
|
}
|
||||||
@@ -1409,12 +1429,16 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totalUsed = inputTokens + outputTokens;
|
const totalUsed = inputTokens + outputTokens;
|
||||||
|
const cacheTokens = cacheReadTokens + cacheCreationTokens;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
used: totalUsed,
|
used: totalUsed,
|
||||||
total: contextWindow,
|
total: contextWindow,
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
|
cacheReadTokens,
|
||||||
|
cacheCreationTokens,
|
||||||
|
cacheTokens,
|
||||||
breakdown: {
|
breakdown: {
|
||||||
input: inputTokens,
|
input: inputTokens,
|
||||||
output: outputTokens
|
output: outputTokens
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type NotificationPreferences = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: boolean;
|
inApp: boolean;
|
||||||
webPush: boolean;
|
webPush: boolean;
|
||||||
|
sound: boolean;
|
||||||
};
|
};
|
||||||
events: {
|
events: {
|
||||||
actionRequired: boolean;
|
actionRequired: boolean;
|
||||||
@@ -22,6 +23,7 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: false,
|
inApp: false,
|
||||||
webPush: false,
|
webPush: false,
|
||||||
|
sound: true,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
actionRequired: true,
|
actionRequired: true,
|
||||||
@@ -37,6 +39,7 @@ function normalizeNotificationPreferences(value: unknown): NotificationPreferenc
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: source.channels?.inApp === true,
|
inApp: source.channels?.inApp === true,
|
||||||
webPush: source.channels?.webPush === true,
|
webPush: source.channels?.webPush === true,
|
||||||
|
sound: source.channels?.sound !== false,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
actionRequired: source.events?.actionRequired !== false,
|
actionRequired: source.events?.actionRequired !== false,
|
||||||
|
|||||||
@@ -67,8 +67,17 @@ function resolveRouteErrorMessage(error: unknown): string {
|
|||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
asyncHandler(async (_req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const projects = await getProjectsWithSessions();
|
const skipSynchronization =
|
||||||
|
readQueryStringValue(req.query.skipSynchronization).trim() === '1' ||
|
||||||
|
readQueryStringValue(req.query.skipSync).trim() === '1';
|
||||||
|
const sessionsLimit = readOptionalNumericQueryValue(req.query.sessionsLimit) ?? undefined;
|
||||||
|
const sessionsOffset = readOptionalNumericQueryValue(req.query.sessionsOffset) ?? undefined;
|
||||||
|
const projects = await getProjectsWithSessions({
|
||||||
|
skipSynchronization,
|
||||||
|
sessionsLimit,
|
||||||
|
sessionsOffset,
|
||||||
|
});
|
||||||
res.json(projects);
|
res.json(projects);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,18 +18,28 @@ export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
|||||||
{
|
{
|
||||||
value: 'default',
|
value: 'default',
|
||||||
label: 'Default (recommended)',
|
label: 'Default (recommended)',
|
||||||
description: 'Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok',
|
description: 'Use the default model (currently Opus 4.8 (1M context)) · $5/$25 per Mtok',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'sonnet',
|
value: 'fable',
|
||||||
label: 'Sonnet',
|
label: 'Fable',
|
||||||
description: 'Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok',
|
description: 'Fable 5 · Most capable for your hardest and longest-running tasks · Uses your limits ~2× faster than Opus',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "sonnet",
|
||||||
|
label: "Sonnet",
|
||||||
|
description: "Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'sonnet[1m]',
|
value: 'sonnet[1m]',
|
||||||
label: 'Sonnet (1M context)',
|
label: 'Sonnet (1M context)',
|
||||||
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok',
|
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'opus[1m]',
|
||||||
|
label: 'Opus 4.8 (1M context)',
|
||||||
|
description: 'Opus 4.8 with 1M context · Most capable for complex work · $5/$25 per Mtok',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: 'haiku',
|
value: 'haiku',
|
||||||
label: 'Haiku',
|
label: 'Haiku',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { readProviderSessionActiveModelChange } from '@/shared/utils.js';
|
|||||||
|
|
||||||
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
|
export const PROVIDER_MODELS_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
|
||||||
const PROVIDER_MODELS_CACHE_VERSION = 1;
|
const PROVIDER_MODELS_CACHE_VERSION = 1;
|
||||||
|
const UNCACHED_PROVIDERS = new Set<LLMProvider>(['claude']);
|
||||||
|
|
||||||
type ProviderModelsServiceDependencies = {
|
type ProviderModelsServiceDependencies = {
|
||||||
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
|
resolveProvider?: (provider: LLMProvider) => Pick<IProvider, 'models'>;
|
||||||
@@ -232,10 +233,42 @@ export const createProviderModelsService = (dependencies: ProviderModelsServiceD
|
|||||||
return request;
|
return request;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadDirectModels = (
|
||||||
|
provider: LLMProvider,
|
||||||
|
): Promise<ProviderModelsResult> => {
|
||||||
|
const request = resolveProvider(provider).models.getSupportedModels()
|
||||||
|
.then((models) => {
|
||||||
|
const currentTime = now();
|
||||||
|
return {
|
||||||
|
models,
|
||||||
|
cache: {
|
||||||
|
updatedAt: new Date(currentTime).toISOString(),
|
||||||
|
expiresAt: new Date(currentTime).toISOString(),
|
||||||
|
source: 'fresh' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
pendingRequests.delete(provider);
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingRequests.set(provider, request);
|
||||||
|
return request;
|
||||||
|
};
|
||||||
|
|
||||||
const getProviderModels = async (
|
const getProviderModels = async (
|
||||||
provider: LLMProvider,
|
provider: LLMProvider,
|
||||||
options: ProviderModelsOptions = {},
|
options: ProviderModelsOptions = {},
|
||||||
): Promise<ProviderModelsResult> => {
|
): Promise<ProviderModelsResult> => {
|
||||||
|
if (UNCACHED_PROVIDERS.has(provider)) {
|
||||||
|
const pendingRequest = pendingRequests.get(provider);
|
||||||
|
if (pendingRequest) {
|
||||||
|
return pendingRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadDirectModels(provider);
|
||||||
|
}
|
||||||
|
|
||||||
if (options.bypassCache) {
|
if (options.bypassCache) {
|
||||||
const pendingRequest = pendingRequests.get(provider);
|
const pendingRequest = pendingRequests.get(provider);
|
||||||
if (pendingRequest) {
|
if (pendingRequest) {
|
||||||
|
|||||||
@@ -130,6 +130,37 @@ test('provider models are cached for the three-day ttl', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('claude provider models are always loaded directly from the provider', async () => {
|
||||||
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-claude-direct-'));
|
||||||
|
let loadCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const service = createProviderModelsService({
|
||||||
|
cachePath: path.join(tempRoot, 'models-cache.json'),
|
||||||
|
resolveProvider: (provider) => ({
|
||||||
|
models: {
|
||||||
|
getSupportedModels: async () => {
|
||||||
|
loadCount += 1;
|
||||||
|
return createModels(`${provider}-${loadCount}`);
|
||||||
|
},
|
||||||
|
getCurrentActiveModel: async () => createCurrentActiveModel(`${provider}-active`),
|
||||||
|
changeActiveModel: async (input) => createSessionActiveModelChange(provider, input),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await service.getProviderModels('claude');
|
||||||
|
const second = await service.getProviderModels('claude');
|
||||||
|
|
||||||
|
assert.equal(loadCount, 2);
|
||||||
|
assert.equal(first.models.DEFAULT, 'claude-1');
|
||||||
|
assert.equal(second.models.DEFAULT, 'claude-2');
|
||||||
|
assert.equal(second.cache.source, 'fresh');
|
||||||
|
} finally {
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('provider model cache is persisted across service instances', async () => {
|
test('provider model cache is persisted across service instances', async () => {
|
||||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-'));
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'provider-model-cache-file-'));
|
||||||
const cachePath = path.join(tempRoot, 'models-cache.json');
|
const cachePath = path.join(tempRoot, 'models-cache.json');
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ export function verifyWebSocketClient(
|
|||||||
dependencies: WebSocketAuthDependencies
|
dependencies: WebSocketAuthDependencies
|
||||||
): boolean {
|
): boolean {
|
||||||
const request = info.req as AuthenticatedWebSocketRequest;
|
const request = info.req as AuthenticatedWebSocketRequest;
|
||||||
console.log('WebSocket connection attempt to:', request.url);
|
const upgradeUrl = new URL(request.url ?? '/', 'http://localhost');
|
||||||
|
const loggedUrl = new URL(upgradeUrl);
|
||||||
|
if (loggedUrl.searchParams.has('token')) {
|
||||||
|
loggedUrl.searchParams.set('token', 'REDACTED');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('WebSocket connection attempt to:', `${loggedUrl.pathname}${loggedUrl.search}`);
|
||||||
|
|
||||||
// Platform mode: use the first DB user and skip token checks.
|
// Platform mode: use the first DB user and skip token checks.
|
||||||
if (dependencies.isPlatform) {
|
if (dependencies.isPlatform) {
|
||||||
@@ -36,7 +42,6 @@ export function verifyWebSocketClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OSS mode: read JWT from query string first, then Authorization header.
|
// OSS mode: read JWT from query string first, then Authorization header.
|
||||||
const upgradeUrl = new URL(request.url ?? '/', 'http://localhost');
|
|
||||||
const token =
|
const token =
|
||||||
upgradeUrl.searchParams.get('token') ??
|
upgradeUrl.searchParams.get('token') ??
|
||||||
request.headers.authorization?.split(' ')[1] ??
|
request.headers.authorization?.split(' ')[1] ??
|
||||||
|
|||||||
@@ -592,12 +592,14 @@ class ResponseCollector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inputTokens = totalInput + totalCacheRead + totalCacheCreation;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inputTokens: totalInput,
|
inputTokens,
|
||||||
outputTokens: totalOutput,
|
outputTokens: totalOutput,
|
||||||
cacheReadTokens: totalCacheRead,
|
cacheReadTokens: totalCacheRead,
|
||||||
cacheCreationTokens: totalCacheCreation,
|
cacheCreationTokens: totalCacheCreation,
|
||||||
totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation
|
totalTokens: inputTokens + totalOutput
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -644,7 +646,7 @@ class ResponseCollector {
|
|||||||
*
|
*
|
||||||
* @param {string} model - (Optional) Model identifier for providers.
|
* @param {string} model - (Optional) Model identifier for providers.
|
||||||
*
|
*
|
||||||
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]'
|
* Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]', 'fable'
|
||||||
* Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
|
* Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',
|
||||||
* 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
|
* 'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',
|
||||||
* 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
|
* 'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',
|
||||||
|
|||||||
@@ -268,16 +268,35 @@ Custom commands can be created in:
|
|||||||
tokenUsage.contextWindow ??
|
tokenUsage.contextWindow ??
|
||||||
0,
|
0,
|
||||||
) || 0;
|
) || 0;
|
||||||
const inputTokensRaw =
|
const normalizedInputValue =
|
||||||
|
tokenUsage.inputTokens ??
|
||||||
|
tokenUsage.input ??
|
||||||
|
tokenUsage.cumulativeInputTokens ??
|
||||||
|
tokenUsage.breakdown?.input ??
|
||||||
|
tokenUsage.promptTokens;
|
||||||
|
const directInputTokens =
|
||||||
Number(
|
Number(
|
||||||
tokenUsage.inputTokens ??
|
normalizedInputValue ??
|
||||||
tokenUsage.input ??
|
|
||||||
tokenUsage.input_tokens ??
|
tokenUsage.input_tokens ??
|
||||||
tokenUsage.cumulativeInputTokens ??
|
0
|
||||||
tokenUsage.breakdown?.input ??
|
) || 0;
|
||||||
tokenUsage.promptTokens ??
|
const cacheReadTokens =
|
||||||
|
Number(
|
||||||
|
tokenUsage.cacheReadTokens ??
|
||||||
|
tokenUsage.cache_read_input_tokens ??
|
||||||
|
tokenUsage.cacheReadInputTokens ??
|
||||||
0,
|
0,
|
||||||
) || 0;
|
) || 0;
|
||||||
|
const cacheCreationTokens =
|
||||||
|
Number(
|
||||||
|
tokenUsage.cacheCreationTokens ??
|
||||||
|
tokenUsage.cache_creation_input_tokens ??
|
||||||
|
tokenUsage.cacheCreationInputTokens ??
|
||||||
|
0,
|
||||||
|
) || 0;
|
||||||
|
const inputTokens = normalizedInputValue == null
|
||||||
|
? directInputTokens + cacheReadTokens + cacheCreationTokens
|
||||||
|
: directInputTokens;
|
||||||
const outputTokens =
|
const outputTokens =
|
||||||
Number(
|
Number(
|
||||||
tokenUsage.outputTokens ??
|
tokenUsage.outputTokens ??
|
||||||
@@ -288,8 +307,9 @@ Custom commands can be created in:
|
|||||||
tokenUsage.completionTokens ??
|
tokenUsage.completionTokens ??
|
||||||
0,
|
0,
|
||||||
) || 0;
|
) || 0;
|
||||||
const hasTokenBreakdown = inputTokensRaw > 0 || outputTokens > 0;
|
const computedUsed = inputTokens + outputTokens;
|
||||||
const used = reportedUsed || inputTokensRaw + outputTokens;
|
const hasTokenBreakdown = computedUsed > 0;
|
||||||
|
const used = Math.max(reportedUsed, computedUsed);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "builtin",
|
type: "builtin",
|
||||||
@@ -302,7 +322,7 @@ Custom commands can be created in:
|
|||||||
...(hasTokenBreakdown
|
...(hasTokenBreakdown
|
||||||
? {
|
? {
|
||||||
tokenBreakdown: {
|
tokenBreakdown: {
|
||||||
input: inputTokensRaw,
|
input: inputTokens,
|
||||||
output: outputTokens,
|
output: outputTokens,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { spawn } from 'child_process';
|
|
||||||
|
import { spawn } from 'cross-spawn';
|
||||||
|
|
||||||
const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
|
const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
|
||||||
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
|
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
|
||||||
|
|||||||
@@ -7,6 +7,41 @@ const runningPlugins = new Map();
|
|||||||
// Map<pluginName, Promise<port>> — in-flight start operations
|
// Map<pluginName, Promise<port>> — in-flight start operations
|
||||||
const startingPlugins = new Map();
|
const startingPlugins = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the environment handed to a plugin server subprocess.
|
||||||
|
*
|
||||||
|
* Intentionally minimal: only non-secret essentials, never the host's full
|
||||||
|
* environment. On Windows a handful of system variables are required for any
|
||||||
|
* child to bootstrap (Node itself, and any Python or CLI a plugin shells out
|
||||||
|
* to). Without APPDATA a `pip install --user` tool cannot locate its
|
||||||
|
* site-packages and fails to import; SystemRoot, PATHEXT and TEMP are needed to
|
||||||
|
* resolve system DLLs, executable extensions and a temp directory. None of
|
||||||
|
* these carry secrets, so the ones that are set get passed straight through.
|
||||||
|
*/
|
||||||
|
function buildPluginEnv(name) {
|
||||||
|
const env = {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
HOME: process.env.HOME,
|
||||||
|
NODE_ENV: process.env.NODE_ENV || 'production',
|
||||||
|
PLUGIN_NAME: name,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const WINDOWS_ESSENTIALS = [
|
||||||
|
'SystemRoot', 'windir', 'SystemDrive',
|
||||||
|
'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
|
||||||
|
'TEMP', 'TMP', 'PATHEXT',
|
||||||
|
];
|
||||||
|
for (const key of WINDOWS_ESSENTIALS) {
|
||||||
|
if (process.env[key] !== undefined) {
|
||||||
|
env[key] = process.env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a plugin's server subprocess.
|
* Start a plugin's server subprocess.
|
||||||
* The plugin's server entry must print a JSON line with { ready: true, port: <number> }
|
* The plugin's server entry must print a JSON line with { ready: true, port: <number> }
|
||||||
@@ -26,15 +61,9 @@ export function startPluginServer(name, pluginDir, serverEntry) {
|
|||||||
|
|
||||||
const serverPath = path.join(pluginDir, serverEntry);
|
const serverPath = path.join(pluginDir, serverEntry);
|
||||||
|
|
||||||
// Restricted env — only essentials, no host secrets
|
|
||||||
const pluginProcess = spawn('node', [serverPath], {
|
const pluginProcess = spawn('node', [serverPath], {
|
||||||
cwd: pluginDir,
|
cwd: pluginDir,
|
||||||
env: {
|
env: buildPluginEnv(name),
|
||||||
PATH: process.env.PATH,
|
|
||||||
HOME: process.env.HOME,
|
|
||||||
NODE_ENV: process.env.NODE_ENV || 'production',
|
|
||||||
PLUGIN_NAME: name,
|
|
||||||
},
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { Brain, Zap, Sparkles, Atom } from 'lucide-react';
|
|
||||||
|
|
||||||
export const thinkingModes = [
|
|
||||||
{
|
|
||||||
id: 'none',
|
|
||||||
name: 'Standard',
|
|
||||||
description: 'Regular Claude response',
|
|
||||||
icon: null,
|
|
||||||
prefix: '',
|
|
||||||
color: 'text-gray-600'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'think',
|
|
||||||
name: 'Think',
|
|
||||||
description: 'Basic extended thinking',
|
|
||||||
icon: Brain,
|
|
||||||
prefix: 'think',
|
|
||||||
color: 'text-blue-600'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'think-hard',
|
|
||||||
name: 'Think Hard',
|
|
||||||
description: 'More thorough evaluation',
|
|
||||||
icon: Zap,
|
|
||||||
prefix: 'think hard',
|
|
||||||
color: 'text-purple-600'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'think-harder',
|
|
||||||
name: 'Think Harder',
|
|
||||||
description: 'Deep analysis with alternatives',
|
|
||||||
icon: Sparkles,
|
|
||||||
prefix: 'think harder',
|
|
||||||
color: 'text-indigo-600'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ultrathink',
|
|
||||||
name: 'Ultrathink',
|
|
||||||
description: 'Maximum thinking budget',
|
|
||||||
icon: Atom,
|
|
||||||
prefix: 'ultrathink',
|
|
||||||
color: 'text-red-600'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
@@ -12,7 +12,6 @@ import type {
|
|||||||
import { useDropzone } from 'react-dropzone';
|
import { useDropzone } from 'react-dropzone';
|
||||||
|
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
import { thinkingModes } from '../constants/thinkingModes';
|
|
||||||
import { grantClaudeToolPermission } from '../utils/chatPermissions';
|
import { grantClaudeToolPermission } from '../utils/chatPermissions';
|
||||||
import { safeLocalStorage } from '../utils/chatStorage';
|
import { safeLocalStorage } from '../utils/chatStorage';
|
||||||
import type {
|
import type {
|
||||||
@@ -204,7 +203,6 @@ export function useChatComposerState({
|
|||||||
const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map());
|
const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map());
|
||||||
const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());
|
const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());
|
||||||
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
||||||
const [thinkingMode, setThinkingMode] = useState('none');
|
|
||||||
const [commandModalPayload, setCommandModalPayload] = useState<CommandModalPayload | null>(null);
|
const [commandModalPayload, setCommandModalPayload] = useState<CommandModalPayload | null>(null);
|
||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -313,7 +311,7 @@ export function useChatComposerState({
|
|||||||
}, [addMessage]);
|
}, [addMessage]);
|
||||||
|
|
||||||
const executeCommand = useCallback(
|
const executeCommand = useCallback(
|
||||||
async (command: SlashCommand, rawInput?: string) => {
|
async (command: SlashCommand, rawInput?: string, options?: { preserveInput?: boolean }) => {
|
||||||
if (!command || !selectedProject) {
|
if (!command || !selectedProject) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -370,8 +368,10 @@ export function useChatComposerState({
|
|||||||
const result = (await response.json()) as CommandExecutionResult;
|
const result = (await response.json()) as CommandExecutionResult;
|
||||||
if (result.type === 'builtin') {
|
if (result.type === 'builtin') {
|
||||||
handleBuiltInCommand(result);
|
handleBuiltInCommand(result);
|
||||||
setInput('');
|
if (!options?.preserveInput) {
|
||||||
inputValueRef.current = '';
|
setInput('');
|
||||||
|
inputValueRef.current = '';
|
||||||
|
}
|
||||||
} else if (result.type === 'custom') {
|
} else if (result.type === 'custom') {
|
||||||
await handleCustomCommand(result);
|
await handleCustomCommand(result);
|
||||||
}
|
}
|
||||||
@@ -402,6 +402,19 @@ export function useChatComposerState({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const showCostModal = useCallback(() => {
|
||||||
|
executeCommand(
|
||||||
|
{
|
||||||
|
name: '/cost',
|
||||||
|
description: 'Display token usage information',
|
||||||
|
namespace: 'builtin',
|
||||||
|
metadata: { type: 'builtin' },
|
||||||
|
} as SlashCommand,
|
||||||
|
'/cost',
|
||||||
|
{ preserveInput: true },
|
||||||
|
);
|
||||||
|
}, [executeCommand]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
slashCommands,
|
slashCommands,
|
||||||
slashCommandsCount,
|
slashCommandsCount,
|
||||||
@@ -562,11 +575,7 @@ export function useChatComposerState({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let messageContent = currentInput;
|
const messageContent = currentInput;
|
||||||
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
|
|
||||||
if (selectedThinkingMode && selectedThinkingMode.prefix) {
|
|
||||||
messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let uploadedImages: unknown[] = [];
|
let uploadedImages: unknown[] = [];
|
||||||
if (attachedImages.length > 0) {
|
if (attachedImages.length > 0) {
|
||||||
@@ -749,7 +758,6 @@ export function useChatComposerState({
|
|||||||
setUploadingImages(new Map());
|
setUploadingImages(new Map());
|
||||||
setImageErrors(new Map());
|
setImageErrors(new Map());
|
||||||
setIsTextareaExpanded(false);
|
setIsTextareaExpanded(false);
|
||||||
setThinkingMode('none');
|
|
||||||
|
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.style.height = 'auto';
|
textareaRef.current.style.height = 'auto';
|
||||||
@@ -783,7 +791,6 @@ export function useChatComposerState({
|
|||||||
setIsLoading,
|
setIsLoading,
|
||||||
setIsUserScrolledUp,
|
setIsUserScrolledUp,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
thinkingMode,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1020,8 +1027,6 @@ export function useChatComposerState({
|
|||||||
textareaRef,
|
textareaRef,
|
||||||
inputHighlightRef,
|
inputHighlightRef,
|
||||||
isTextareaExpanded,
|
isTextareaExpanded,
|
||||||
thinkingMode,
|
|
||||||
setThinkingMode,
|
|
||||||
slashCommandsCount,
|
slashCommandsCount,
|
||||||
filteredCommands,
|
filteredCommands,
|
||||||
frequentCommands,
|
frequentCommands,
|
||||||
@@ -1059,5 +1064,6 @@ export function useChatComposerState({
|
|||||||
isInputFocused,
|
isInputFocused,
|
||||||
commandModalPayload,
|
commandModalPayload,
|
||||||
closeCommandModal,
|
closeCommandModal,
|
||||||
|
showCostModal,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import type { NormalizedMessage } from '../../../stores/useSessionStore';
|
|||||||
import type { ChatMessage, SubagentChildTool } from '../types/types';
|
import type { ChatMessage, SubagentChildTool } from '../types/types';
|
||||||
import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting';
|
import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting';
|
||||||
|
|
||||||
|
function formatToolResultContent(content: unknown): string {
|
||||||
|
const text = typeof content === 'string' ? content : JSON.stringify(content);
|
||||||
|
const toolUseErrorMatch = /^<tool_use_error>([\s\S]*)<\/tool_use_error>$/.exec(text.trim());
|
||||||
|
return toolUseErrorMatch ? toolUseErrorMatch[1] : text;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert NormalizedMessage[] from the session store into ChatMessage[]
|
* Convert NormalizedMessage[] from the session store into ChatMessage[]
|
||||||
* that the existing UI components expect.
|
* that the existing UI components expect.
|
||||||
@@ -20,7 +26,12 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
|||||||
|
|
||||||
// First pass: collect tool results for attachment
|
// First pass: collect tool results for attachment
|
||||||
const toolResultMap = new Map<string, NormalizedMessage>();
|
const toolResultMap = new Map<string, NormalizedMessage>();
|
||||||
|
const toolUseIds = new Set<string>();
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
|
if (msg.kind === 'tool_use' && msg.toolId) {
|
||||||
|
toolUseIds.add(msg.toolId);
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.kind === 'tool_result' && msg.toolId) {
|
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||||
toolResultMap.set(msg.toolId, msg);
|
toolResultMap.set(msg.toolId, msg);
|
||||||
}
|
}
|
||||||
@@ -97,7 +108,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
|||||||
|
|
||||||
const toolResult = tr
|
const toolResult = tr
|
||||||
? {
|
? {
|
||||||
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
|
content: formatToolResultContent(tr.content),
|
||||||
isError: Boolean(tr.isError),
|
isError: Boolean(tr.isError),
|
||||||
toolUseResult: (tr as any).toolUseResult,
|
toolUseResult: (tr as any).toolUseResult,
|
||||||
}
|
}
|
||||||
@@ -191,8 +202,25 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
// tool_result is handled via attachment to tool_use above
|
// tool_result is handled via attachment to tool_use above
|
||||||
case 'tool_result':
|
case 'tool_result': {
|
||||||
|
if (msg.toolId && toolUseIds.has(msg.toolId)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = formatToolResultContent(msg.content || '');
|
||||||
|
if (!content.trim()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
converted.push({
|
||||||
|
type: msg.isError ? 'error' : 'assistant',
|
||||||
|
content,
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
toolId: msg.toolId,
|
||||||
|
...sharedMetadata,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useEffect, useRef } from 'react';
|
|||||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
||||||
|
|
||||||
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
||||||
|
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
|
||||||
|
import { playChatCompletionSound } from '../../../utils/notificationSound';
|
||||||
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
|
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
|
||||||
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
||||||
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
||||||
@@ -285,6 +287,9 @@ export function useChatRealtimeHandlers({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showCompletionTitleIndicator();
|
||||||
|
void playChatCompletionSound();
|
||||||
|
|
||||||
const actualSessionId =
|
const actualSessionId =
|
||||||
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
|
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
|
||||||
? msg.actualSessionId
|
? msg.actualSessionId
|
||||||
|
|||||||
@@ -383,12 +383,47 @@ export function useChatSessionState({
|
|||||||
setIsUserScrolledUp(false);
|
setIsUserScrolledUp(false);
|
||||||
}, [selectedProject?.projectId, selectedSession?.id]);
|
}, [selectedProject?.projectId, selectedSession?.id]);
|
||||||
|
|
||||||
// Initial scroll to bottom
|
// Initial scroll to bottom — robust to lazy content reflow.
|
||||||
|
// The previous implementation fired one scrollToBottom() at +200ms and
|
||||||
|
// cleared the pending flag. When markdown blocks, code highlighting, or
|
||||||
|
// images finished rendering after that window, scrollHeight grew but
|
||||||
|
// nothing re-anchored the viewport, leaving the chat tab visually
|
||||||
|
// "scrolled way up" with the latest assistant message off-screen.
|
||||||
|
//
|
||||||
|
// This version re-scrolls every animation frame while scrollHeight is
|
||||||
|
// still growing, capped at ~1s (60 frames) or 3 consecutive stable
|
||||||
|
// frames. Cancels cleanly on session change via the pending flag.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return;
|
if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return;
|
||||||
if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; }
|
if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; }
|
||||||
pendingInitialScrollRef.current = false;
|
if (searchScrollActiveRef.current) { pendingInitialScrollRef.current = false; return; }
|
||||||
if (!searchScrollActiveRef.current) setTimeout(() => scrollToBottom(), 200);
|
|
||||||
|
const container = scrollContainerRef.current;
|
||||||
|
let frame = 0;
|
||||||
|
let lastHeight = 0;
|
||||||
|
let stableCount = 0;
|
||||||
|
let rafId = 0;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (!pendingInitialScrollRef.current || !scrollContainerRef.current) return;
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
if (container.scrollHeight === lastHeight) {
|
||||||
|
stableCount++;
|
||||||
|
} else {
|
||||||
|
stableCount = 0;
|
||||||
|
lastHeight = container.scrollHeight;
|
||||||
|
}
|
||||||
|
frame++;
|
||||||
|
if (stableCount < 3 && frame < 60) {
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
} else {
|
||||||
|
pendingInitialScrollRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
return () => {
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
};
|
||||||
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
|
}, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);
|
||||||
|
|
||||||
// Main session loading effect — store-based
|
// Main session loading effect — store-based
|
||||||
|
|||||||
@@ -393,7 +393,8 @@ export function useSlashCommands({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slashPattern = /^\/(\S*)$/;
|
// Match / at start of input OR after whitespace, capturing the /word up to cursor.
|
||||||
|
const slashPattern = /(?:^|\s)(\/\S*)$/;
|
||||||
const match = textBeforeCursor.match(slashPattern);
|
const match = textBeforeCursor.match(slashPattern);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -401,8 +402,9 @@ export function useSlashCommands({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const slashPos = 0;
|
// Compute actual position of / in the full input string.
|
||||||
const query = match[1];
|
const slashPos = match.index! + (match[0].length - match[1].length);
|
||||||
|
const query = match[1].slice(1); // strip leading /
|
||||||
|
|
||||||
setSlashPosition(slashPos);
|
setSlashPosition(slashPos);
|
||||||
setShowCommandMenu(true);
|
setShowCommandMenu(true);
|
||||||
|
|||||||
@@ -564,11 +564,15 @@ export function shouldHideToolResult(toolName: string, toolResult: any): boolean
|
|||||||
|
|
||||||
if (!config.result) return false;
|
if (!config.result) return false;
|
||||||
|
|
||||||
|
// Hidden/success-only configs suppress noisy successful output, but errors
|
||||||
|
// still need to be visible so failed tool calls are diagnosable.
|
||||||
|
if (toolResult?.isError) return false;
|
||||||
|
|
||||||
// Always hidden
|
// Always hidden
|
||||||
if (config.result.hidden) return true;
|
if (config.result.hidden) return true;
|
||||||
|
|
||||||
// Hide on success only
|
// Hide on success only
|
||||||
if (config.result.hideOnSuccess && toolResult && !toolResult.isError) {
|
if (config.result.hideOnSuccess && toolResult) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,8 +141,6 @@ function ChatInterface({
|
|||||||
textareaRef,
|
textareaRef,
|
||||||
inputHighlightRef,
|
inputHighlightRef,
|
||||||
isTextareaExpanded,
|
isTextareaExpanded,
|
||||||
thinkingMode,
|
|
||||||
setThinkingMode,
|
|
||||||
slashCommandsCount,
|
slashCommandsCount,
|
||||||
filteredCommands,
|
filteredCommands,
|
||||||
frequentCommands,
|
frequentCommands,
|
||||||
@@ -180,6 +178,7 @@ function ChatInterface({
|
|||||||
isInputFocused: _isInputFocused,
|
isInputFocused: _isInputFocused,
|
||||||
commandModalPayload,
|
commandModalPayload,
|
||||||
closeCommandModal,
|
closeCommandModal,
|
||||||
|
showCostModal,
|
||||||
} = useChatComposerState({
|
} = useChatComposerState({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
@@ -369,9 +368,8 @@ function ChatInterface({
|
|||||||
provider={provider}
|
provider={provider}
|
||||||
permissionMode={permissionMode}
|
permissionMode={permissionMode}
|
||||||
onModeSwitch={cyclePermissionMode}
|
onModeSwitch={cyclePermissionMode}
|
||||||
thinkingMode={thinkingMode}
|
|
||||||
setThinkingMode={setThinkingMode}
|
|
||||||
tokenBudget={tokenBudget}
|
tokenBudget={tokenBudget}
|
||||||
|
onShowTokenUsage={showCostModal}
|
||||||
slashCommandsCount={slashCommandsCount}
|
slashCommandsCount={slashCommandsCount}
|
||||||
onToggleCommandMenu={handleToggleCommandMenu}
|
onToggleCommandMenu={handleToggleCommandMenu}
|
||||||
hasInput={Boolean(input.trim())}
|
hasInput={Boolean(input.trim())}
|
||||||
|
|||||||
@@ -2,23 +2,16 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import type {
|
import type {
|
||||||
ChangeEvent,
|
ChangeEvent,
|
||||||
ClipboardEvent,
|
ClipboardEvent,
|
||||||
Dispatch,
|
|
||||||
FormEvent,
|
FormEvent,
|
||||||
KeyboardEvent,
|
KeyboardEvent,
|
||||||
MouseEvent,
|
MouseEvent,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
RefObject,
|
RefObject,
|
||||||
SetStateAction,
|
|
||||||
TouchEvent,
|
TouchEvent,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
|
import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon } from 'lucide-react';
|
||||||
|
|
||||||
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
|
import type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';
|
||||||
import CommandMenu from './CommandMenu';
|
|
||||||
import ClaudeStatus from './ClaudeStatus';
|
|
||||||
import ImageAttachment from './ImageAttachment';
|
|
||||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
|
||||||
import ThinkingModeSelector from './ThinkingModeSelector';
|
|
||||||
import TokenUsageSummary from './TokenUsageSummary';
|
|
||||||
import {
|
import {
|
||||||
PromptInput,
|
PromptInput,
|
||||||
PromptInputHeader,
|
PromptInputHeader,
|
||||||
@@ -30,6 +23,12 @@ import {
|
|||||||
PromptInputSubmit,
|
PromptInputSubmit,
|
||||||
} from '../../../../shared/view/ui';
|
} from '../../../../shared/view/ui';
|
||||||
|
|
||||||
|
import CommandMenu from './CommandMenu';
|
||||||
|
import ClaudeStatus from './ClaudeStatus';
|
||||||
|
import ImageAttachment from './ImageAttachment';
|
||||||
|
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||||
|
import TokenUsageSummary from './TokenUsageSummary';
|
||||||
|
|
||||||
interface MentionableFile {
|
interface MentionableFile {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -58,9 +57,8 @@ interface ChatComposerProps {
|
|||||||
provider: Provider | string;
|
provider: Provider | string;
|
||||||
permissionMode: PermissionMode | string;
|
permissionMode: PermissionMode | string;
|
||||||
onModeSwitch: () => void;
|
onModeSwitch: () => void;
|
||||||
thinkingMode: string;
|
|
||||||
setThinkingMode: Dispatch<SetStateAction<string>>;
|
|
||||||
tokenBudget: Record<string, unknown> | null;
|
tokenBudget: Record<string, unknown> | null;
|
||||||
|
onShowTokenUsage: () => void;
|
||||||
slashCommandsCount: number;
|
slashCommandsCount: number;
|
||||||
onToggleCommandMenu: () => void;
|
onToggleCommandMenu: () => void;
|
||||||
hasInput: boolean;
|
hasInput: boolean;
|
||||||
@@ -113,9 +111,8 @@ export default function ChatComposer({
|
|||||||
provider,
|
provider,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
onModeSwitch,
|
onModeSwitch,
|
||||||
thinkingMode,
|
|
||||||
setThinkingMode,
|
|
||||||
tokenBudget,
|
tokenBudget,
|
||||||
|
onShowTokenUsage,
|
||||||
slashCommandsCount,
|
slashCommandsCount,
|
||||||
onToggleCommandMenu,
|
onToggleCommandMenu,
|
||||||
hasInput,
|
hasInput,
|
||||||
@@ -358,11 +355,7 @@ export default function ChatComposer({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{provider === 'claude' && (
|
<TokenUsageSummary usage={tokenBudget} onClick={onShowTokenUsage} />
|
||||||
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TokenUsageSummary usage={tokenBudget} />
|
|
||||||
|
|
||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
tooltip={{ content: t('input.showAllCommands') }}
|
tooltip={{ content: t('input.showAllCommands') }}
|
||||||
@@ -383,7 +376,7 @@ export default function ChatComposer({
|
|||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
tooltip={{ content: t('input.clearInput', { defaultValue: 'Clear input' }) }}
|
tooltip={{ content: t('input.clearInput', { defaultValue: 'Clear input' }) }}
|
||||||
onClick={onClearInput}
|
onClick={onClearInput}
|
||||||
className="hidden sm:No-flex"
|
className="hidden sm:flex"
|
||||||
>
|
>
|
||||||
<XIcon />
|
<XIcon />
|
||||||
</PromptInputButton>
|
</PromptInputButton>
|
||||||
@@ -400,7 +393,8 @@ export default function ChatComposer({
|
|||||||
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
|
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
|
||||||
</div>
|
</div>
|
||||||
<PromptInputSubmit
|
<PromptInputSubmit
|
||||||
disabled={!input.trim() || isLoading}
|
onClick={isLoading ? onAbortSession : undefined}
|
||||||
|
disabled={!isLoading && !input.trim()}
|
||||||
className="h-10 w-10 sm:h-10 sm:w-10"
|
className="h-10 w-10 sm:h-10 sm:w-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||||
import type {
|
import type {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
@@ -8,10 +9,10 @@ import type {
|
|||||||
Provider,
|
Provider,
|
||||||
} from '../../types/types';
|
} from '../../types/types';
|
||||||
import { formatUsageLimitText } from '../../utils/chatFormatting';
|
import { formatUsageLimitText } from '../../utils/chatFormatting';
|
||||||
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
|
|
||||||
import type { Project } from '../../../../types/app';
|
import type { Project } from '../../../../types/app';
|
||||||
import { ToolRenderer, shouldHideToolResult } from '../../tools';
|
import { ToolRenderer, shouldHideToolResult } from '../../tools';
|
||||||
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
|
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
|
||||||
|
|
||||||
import { Markdown } from './Markdown';
|
import { Markdown } from './Markdown';
|
||||||
import MessageCopyControl from './MessageCopyControl';
|
import MessageCopyControl from './MessageCopyControl';
|
||||||
|
|
||||||
@@ -41,10 +42,9 @@ type InteractiveOption = {
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PermissionGrantState = 'idle' | 'granted' | 'error';
|
|
||||||
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
|
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
|
||||||
|
|
||||||
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||||
((prevMessage.type === 'assistant') ||
|
((prevMessage.type === 'assistant') ||
|
||||||
@@ -53,8 +53,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
|||||||
(prevMessage.type === 'error'));
|
(prevMessage.type === 'error'));
|
||||||
const messageRef = useRef<HTMLDivElement | null>(null);
|
const messageRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
|
|
||||||
const [permissionGrantState, setPermissionGrantState] = useState<PermissionGrantState>('idle');
|
|
||||||
const userCopyContent = String(message.content || '');
|
const userCopyContent = String(message.content || '');
|
||||||
const formattedMessageContent = useMemo(
|
const formattedMessageContent = useMemo(
|
||||||
() => formatUsageLimitText(String(message.content || '')),
|
() => formatUsageLimitText(String(message.content || '')),
|
||||||
@@ -73,10 +71,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
|||||||
!message.isThinking;
|
!message.isThinking;
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPermissionGrantState('idle');
|
|
||||||
}, [permissionSuggestion?.entry, message.toolId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const node = messageRef.current;
|
const node = messageRef.current;
|
||||||
if (!autoExpandTools || !node || !message.isToolUse) return;
|
if (!autoExpandTools || !node || !message.isToolUse) return;
|
||||||
@@ -241,55 +235,6 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
|||||||
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
|
<Markdown className="prose prose-sm prose-red max-w-none dark:prose-invert">
|
||||||
{String(message.toolResult.content || '')}
|
{String(message.toolResult.content || '')}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
{permissionSuggestion && (
|
|
||||||
<div className="mt-4 border-t border-red-200/60 pt-3 dark:border-red-800/60">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (!onGrantToolPermission) return;
|
|
||||||
const result = onGrantToolPermission(permissionSuggestion);
|
|
||||||
if (result?.success) {
|
|
||||||
setPermissionGrantState('granted');
|
|
||||||
} else {
|
|
||||||
setPermissionGrantState('error');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}
|
|
||||||
className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
|
||||||
? 'cursor-default border-green-300/70 bg-green-100 text-green-800 dark:border-green-800/60 dark:bg-green-900/30 dark:text-green-200'
|
|
||||||
: 'border-red-300/70 bg-white/80 text-red-700 hover:bg-white dark:border-red-800/60 dark:bg-gray-900/40 dark:text-red-200 dark:hover:bg-gray-900/70'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
|
||||||
? t('permissions.added')
|
|
||||||
: t('permissions.grant', { tool: permissionSuggestion.toolName })}
|
|
||||||
</button>
|
|
||||||
{onShowSettings && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => { e.stopPropagation(); onShowSettings(); }}
|
|
||||||
className="text-xs text-red-700 underline hover:text-red-800 dark:text-red-200 dark:hover:text-red-100"
|
|
||||||
>
|
|
||||||
{t('permissions.openSettings')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs text-red-700/90 dark:text-red-200/80">
|
|
||||||
{t('permissions.addTo', { entry: permissionSuggestion.entry })}
|
|
||||||
</div>
|
|
||||||
{permissionGrantState === 'error' && (
|
|
||||||
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
|
|
||||||
{t('permissions.error')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (
|
|
||||||
<div className="mt-2 text-xs text-green-700 dark:text-green-200">
|
|
||||||
{t('permissions.retry')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -277,11 +277,15 @@ export default function ProviderSelectionEmptyState({
|
|||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate">{model.label}</div>
|
<div className="truncate">{model.label}</div>
|
||||||
{model.description && (
|
{/*
|
||||||
|
// * Temporarly commented out because the description of models from claude
|
||||||
|
// * was a bit inconsistent. Will return it back when it becomes more consistent.
|
||||||
|
*/}
|
||||||
|
{/* {model.description && (
|
||||||
<div className="truncate text-xs text-muted-foreground">
|
<div className="truncate text-xs text-muted-foreground">
|
||||||
{model.description}
|
{model.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)} */}
|
||||||
</div>
|
</div>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
|
<Check className="ml-auto h-4 w-4 shrink-0 text-primary" />
|
||||||
|
|||||||
@@ -1,244 +0,0 @@
|
|||||||
import { useState, useRef, useEffect, useCallback, type CSSProperties } from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { Brain, X } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { thinkingModes } from '../../constants/thinkingModes';
|
|
||||||
|
|
||||||
type ThinkingModeSelectorProps = {
|
|
||||||
selectedMode: string;
|
|
||||||
onModeChange: (modeId: string) => void;
|
|
||||||
onClose?: () => void;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) {
|
|
||||||
const { t } = useTranslation('chat');
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties | null>(null);
|
|
||||||
|
|
||||||
// Mapping from mode ID to translation key
|
|
||||||
const modeKeyMap: Record<string, string> = {
|
|
||||||
'think-hard': 'thinkHard',
|
|
||||||
'think-harder': 'thinkHarder'
|
|
||||||
};
|
|
||||||
// Create translated modes for display
|
|
||||||
const translatedModes = thinkingModes.map(mode => {
|
|
||||||
const modeKey = modeKeyMap[mode.id] || mode.id;
|
|
||||||
return {
|
|
||||||
...mode,
|
|
||||||
name: t(`thinkingMode.modes.${modeKey}.name`),
|
|
||||||
description: t(`thinkingMode.modes.${modeKey}.description`),
|
|
||||||
prefix: t(`thinkingMode.modes.${modeKey}.prefix`)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const closeDropdown = useCallback(() => {
|
|
||||||
setIsOpen(false);
|
|
||||||
onClose?.();
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const updateDropdownPosition = useCallback(() => {
|
|
||||||
const trigger = triggerRef.current;
|
|
||||||
const dropdown = dropdownRef.current;
|
|
||||||
if (!trigger || !dropdown || typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggerRect = trigger.getBoundingClientRect();
|
|
||||||
const viewportPadding = window.innerWidth < 640 ? 12 : 16;
|
|
||||||
const spacing = 8;
|
|
||||||
const width = Math.min(window.innerWidth - viewportPadding * 2, window.innerWidth < 640 ? 320 : 256);
|
|
||||||
let left = triggerRect.left + triggerRect.width / 2 - width / 2;
|
|
||||||
left = Math.max(viewportPadding, Math.min(left, window.innerWidth - width - viewportPadding));
|
|
||||||
|
|
||||||
const measuredHeight = dropdown.offsetHeight || 0;
|
|
||||||
const spaceBelow = window.innerHeight - triggerRect.bottom - spacing - viewportPadding;
|
|
||||||
const spaceAbove = triggerRect.top - spacing - viewportPadding;
|
|
||||||
const openBelow = spaceBelow >= Math.min(measuredHeight || 320, 320) || spaceBelow >= spaceAbove;
|
|
||||||
const availableHeight = Math.min(
|
|
||||||
window.innerHeight - viewportPadding * 2,
|
|
||||||
Math.max(180, openBelow ? spaceBelow : spaceAbove),
|
|
||||||
);
|
|
||||||
const panelHeight = Math.min(measuredHeight || availableHeight, availableHeight);
|
|
||||||
const top = openBelow
|
|
||||||
? Math.min(triggerRect.bottom + spacing, window.innerHeight - viewportPadding - panelHeight)
|
|
||||||
: Math.max(viewportPadding, triggerRect.top - spacing - panelHeight);
|
|
||||||
|
|
||||||
setDropdownStyle({
|
|
||||||
position: 'fixed',
|
|
||||||
top,
|
|
||||||
left,
|
|
||||||
width,
|
|
||||||
maxHeight: availableHeight,
|
|
||||||
zIndex: 80,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
setDropdownStyle(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rafId = window.requestAnimationFrame(updateDropdownPosition);
|
|
||||||
const handleViewportChange = () => updateDropdownPosition();
|
|
||||||
|
|
||||||
window.addEventListener('resize', handleViewportChange);
|
|
||||||
window.addEventListener('scroll', handleViewportChange, true);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.cancelAnimationFrame(rafId);
|
|
||||||
window.removeEventListener('resize', handleViewportChange);
|
|
||||||
window.removeEventListener('scroll', handleViewportChange, true);
|
|
||||||
};
|
|
||||||
}, [isOpen, updateDropdownPosition]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePointerDown = (event: PointerEvent) => {
|
|
||||||
const target = event.target;
|
|
||||||
if (!(target instanceof Node)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (containerRef.current?.contains(target) || dropdownRef.current?.contains(target)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
closeDropdown();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
closeDropdown();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [isOpen, closeDropdown]);
|
|
||||||
|
|
||||||
const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0];
|
|
||||||
const IconComponent = currentMode.icon || Brain;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`relative ${className}`} ref={containerRef}>
|
|
||||||
<button
|
|
||||||
ref={triggerRef}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (isOpen) {
|
|
||||||
closeDropdown();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsOpen(true);
|
|
||||||
}}
|
|
||||||
className={`flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200 sm:h-10 sm:w-10 ${selectedMode === 'none'
|
|
||||||
? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'
|
|
||||||
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800'
|
|
||||||
}`}
|
|
||||||
title={t('thinkingMode.buttonTitle', { mode: currentMode.name })}
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
>
|
|
||||||
<IconComponent className={`h-5 w-5 ${currentMode.color}`} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isOpen && typeof document !== 'undefined' && createPortal(
|
|
||||||
<div
|
|
||||||
ref={dropdownRef}
|
|
||||||
style={dropdownStyle || { position: 'fixed', top: 0, left: 0, visibility: 'hidden' }}
|
|
||||||
className="flex flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="false"
|
|
||||||
>
|
|
||||||
<div className="border-b border-gray-200 p-3 dark:border-gray-700">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
|
||||||
{t('thinkingMode.selector.title')}
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeDropdown}
|
|
||||||
className="rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4 text-gray-500" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{t('thinkingMode.selector.description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-h-0 overflow-y-auto py-1">
|
|
||||||
{translatedModes.map((mode) => {
|
|
||||||
const ModeIcon = mode.icon;
|
|
||||||
const isSelected = mode.id === selectedMode;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={mode.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onModeChange(mode.id);
|
|
||||||
closeDropdown();
|
|
||||||
}}
|
|
||||||
className={`w-full px-4 py-3 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 ${isSelected ? 'bg-gray-50 dark:bg-gray-700' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className={`mt-0.5 ${mode.icon ? mode.color : 'text-gray-400'}`}>
|
|
||||||
{ModeIcon ? <ModeIcon className="h-5 w-5" /> : <div className="h-5 w-5" />}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`text-sm font-medium ${isSelected ? 'text-gray-900 dark:text-white' : 'text-gray-700 dark:text-gray-300'
|
|
||||||
}`}>
|
|
||||||
{mode.name}
|
|
||||||
</span>
|
|
||||||
{isSelected && (
|
|
||||||
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900 dark:text-blue-300">
|
|
||||||
{t('thinkingMode.selector.active')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{mode.description}
|
|
||||||
</p>
|
|
||||||
{mode.prefix && (
|
|
||||||
<code className="mt-1 inline-block rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-700">
|
|
||||||
{mode.prefix}
|
|
||||||
</code>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900">
|
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
<strong>Tip:</strong> {t('thinkingMode.selector.tip')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ThinkingModeSelector;
|
|
||||||
@@ -2,6 +2,7 @@ import { ActivityIcon } from 'lucide-react';
|
|||||||
|
|
||||||
type TokenUsageSummaryProps = {
|
type TokenUsageSummaryProps = {
|
||||||
usage: Record<string, unknown> | null;
|
usage: Record<string, unknown> | null;
|
||||||
|
onClick?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTokenCount = (value: number) => {
|
const formatTokenCount = (value: number) => {
|
||||||
@@ -29,7 +30,7 @@ const readUsageNumber = (value: unknown) => {
|
|||||||
return Number.isFinite(parsed) ? parsed : 0;
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TokenUsageSummary({ usage }: TokenUsageSummaryProps) {
|
export default function TokenUsageSummary({ usage, onClick }: TokenUsageSummaryProps) {
|
||||||
const breakdown =
|
const breakdown =
|
||||||
usage?.breakdown && typeof usage.breakdown === 'object'
|
usage?.breakdown && typeof usage.breakdown === 'object'
|
||||||
? usage.breakdown as Record<string, unknown>
|
? usage.breakdown as Record<string, unknown>
|
||||||
@@ -39,15 +40,18 @@ export default function TokenUsageSummary({ usage }: TokenUsageSummaryProps) {
|
|||||||
const usedTokens = readUsageNumber(usage?.used) || inputTokens + outputTokens;
|
const usedTokens = readUsageNumber(usage?.used) || inputTokens + outputTokens;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground sm:gap-2 sm:px-2.5"
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:gap-2 sm:px-2.5"
|
||||||
title={`${usedTokens.toLocaleString()} tokens used`}
|
title={`${usedTokens.toLocaleString()} tokens used`}
|
||||||
|
aria-label="Show token usage"
|
||||||
>
|
>
|
||||||
<span className="grid h-5 w-5 place-items-center rounded-md bg-primary/10 text-primary">
|
<span className="grid h-5 w-5 place-items-center rounded-md bg-primary/10 text-primary">
|
||||||
<ActivityIcon className="h-3.5 w-3.5" />
|
<ActivityIcon className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium text-foreground">{formatTokenCount(usedTokens)}</span>
|
<span className="font-medium text-foreground">{formatTokenCount(usedTokens)}</span>
|
||||||
<span className="hidden text-muted-foreground/70 sm:inline">tokens</span>
|
<span className="hidden text-muted-foreground/70 sm:inline">tokens</span>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function EditorSidebar({
|
|||||||
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
|
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className={`flex h-full min-w-0 flex-shrink-0 ${editorExpanded ? 'flex-1' : ''}`}>
|
<div ref={containerRef} className={`flex h-full min-w-0 ${editorExpanded ? 'flex-1' : ''}`}>
|
||||||
{!editorExpanded && (
|
{!editorExpanded && (
|
||||||
<div
|
<div
|
||||||
ref={resizeHandleRef}
|
ref={resizeHandleRef}
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ export const FILE_TREE_DEFAULT_VIEW_MODE: FileTreeViewMode = 'detailed';
|
|||||||
|
|
||||||
export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed'];
|
export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed'];
|
||||||
|
|
||||||
|
export const MAX_FILE_UPLOAD_SIZE_MB = 200;
|
||||||
|
|
||||||
|
export const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MB * 1024 * 1024;
|
||||||
|
|
||||||
|
export const MAX_FILE_UPLOAD_SIZE_LABEL = `${MAX_FILE_UPLOAD_SIZE_MB}MB`;
|
||||||
|
|
||||||
|
export const MAX_FILE_UPLOAD_COUNT = 20;
|
||||||
|
|
||||||
export const IMAGE_FILE_EXTENSIONS = new Set([
|
export const IMAGE_FILE_EXTENSIONS = new Set([
|
||||||
'png',
|
'png',
|
||||||
'jpg',
|
'jpg',
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { useCallback, useState, useRef } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import type { DragEvent } from 'react';
|
||||||
|
|
||||||
|
import { IS_PLATFORM } from '../../../constants/config';
|
||||||
import type { Project } from '../../../types/app';
|
import type { Project } from '../../../types/app';
|
||||||
import { api } from '../../../utils/api';
|
import {
|
||||||
|
MAX_FILE_UPLOAD_COUNT,
|
||||||
|
MAX_FILE_UPLOAD_SIZE_BYTES,
|
||||||
|
MAX_FILE_UPLOAD_SIZE_LABEL,
|
||||||
|
} from '../constants/constants';
|
||||||
|
|
||||||
type UseFileTreeUploadOptions = {
|
type UseFileTreeUploadOptions = {
|
||||||
selectedProject: Project | null;
|
selectedProject: Project | null;
|
||||||
@@ -8,6 +15,141 @@ type UseFileTreeUploadOptions = {
|
|||||||
showToast: (message: string, type: 'success' | 'error') => void;
|
showToast: (message: string, type: 'success' | 'error') => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FileTreeUploadProgressState = {
|
||||||
|
status: 'uploading' | 'complete' | 'error';
|
||||||
|
progress: number;
|
||||||
|
fileCount: number;
|
||||||
|
uploadedCount?: number;
|
||||||
|
fileName?: string;
|
||||||
|
targetPath?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UploadResponse = {
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
files?: unknown[];
|
||||||
|
uploadedCount?: number;
|
||||||
|
requestedFileCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMPLETE_PROGRESS_CLEAR_DELAY_MS = 1400;
|
||||||
|
const ERROR_PROGRESS_CLEAR_DELAY_MS = 3200;
|
||||||
|
|
||||||
|
const pluralizeFiles = (count: number) => (count === 1 ? 'file' : 'files');
|
||||||
|
|
||||||
|
const getRelativePath = (file: File) => {
|
||||||
|
const fileWithRelativePath = file as File & { webkitRelativePath?: string };
|
||||||
|
return fileWithRelativePath.webkitRelativePath || file.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFileDisplayName = (file: File) => {
|
||||||
|
const relativePath = getRelativePath(file);
|
||||||
|
return relativePath.split(/[\\/]/).pop() || file.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateFilesForUpload = (files: File[]): string | null => {
|
||||||
|
if (files.length > MAX_FILE_UPLOAD_COUNT) {
|
||||||
|
return `You can upload up to ${MAX_FILE_UPLOAD_COUNT} files at once.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oversizedFile = files.find((file) => file.size > MAX_FILE_UPLOAD_SIZE_BYTES);
|
||||||
|
if (oversizedFile) {
|
||||||
|
return `${getFileDisplayName(oversizedFile)} is larger than ${MAX_FILE_UPLOAD_SIZE_LABEL}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseUploadResponse = (xhr: XMLHttpRequest): UploadResponse => {
|
||||||
|
if (!xhr.responseText) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(xhr.responseText) as UploadResponse;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatUploadSuccessMessage = (uploadedCount: number, requestedFileCount: number) => {
|
||||||
|
if (uploadedCount !== requestedFileCount) {
|
||||||
|
return `Uploaded ${uploadedCount} of ${requestedFileCount} ${pluralizeFiles(requestedFileCount)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Uploaded ${uploadedCount} ${pluralizeFiles(uploadedCount)} successfully`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildUploadFormData = (files: File[], targetPath: string) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
const relativePaths: string[] = [];
|
||||||
|
|
||||||
|
formData.append('targetPath', targetPath);
|
||||||
|
formData.append('requestedFileCount', String(files.length));
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
const relativePath = getRelativePath(file);
|
||||||
|
const cleanFile = new File([file], relativePath.split(/[\\/]/).pop() || file.name, {
|
||||||
|
type: file.type,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
});
|
||||||
|
|
||||||
|
formData.append('files', cleanFile);
|
||||||
|
relativePaths.push(relativePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
formData.append('relativePaths', JSON.stringify(relativePaths));
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFormDataWithProgress = (
|
||||||
|
projectId: string,
|
||||||
|
formData: FormData,
|
||||||
|
onProgress: (progress: number) => void,
|
||||||
|
) =>
|
||||||
|
new Promise<UploadResponse>((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
xhr.open('POST', `/api/projects/${encodeURIComponent(projectId)}/files/upload`);
|
||||||
|
|
||||||
|
const token = localStorage.getItem('auth-token');
|
||||||
|
if (!IS_PLATFORM && token) {
|
||||||
|
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (!event.lengthComputable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep 100% for the server response so the UI can distinguish transfer
|
||||||
|
// completion from the final write/refresh step.
|
||||||
|
onProgress(Math.min(99, Math.round((event.loaded / event.total) * 100)));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
const refreshedToken = xhr.getResponseHeader('X-Refreshed-Token');
|
||||||
|
if (refreshedToken) {
|
||||||
|
localStorage.setItem('auth-token', refreshedToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseUploadResponse(xhr);
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
resolve(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error(payload.error || payload.message || `Upload failed with status ${xhr.status}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => reject(new Error('Upload failed. Check your connection and try again.'));
|
||||||
|
xhr.onabort = () => reject(new Error('Upload canceled.'));
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
|
||||||
// Helper function to read all files from a directory entry recursively
|
// Helper function to read all files from a directory entry recursively
|
||||||
const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, basePath = ''): Promise<File[]> => {
|
const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, basePath = ''): Promise<File[]> => {
|
||||||
const files: File[] = [];
|
const files: File[] = [];
|
||||||
@@ -57,6 +199,48 @@ const readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry,
|
|||||||
return files;
|
return files;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const collectDroppedFiles = async (dataTransfer: DataTransfer) => {
|
||||||
|
const files: File[] = [];
|
||||||
|
|
||||||
|
// Use DataTransferItemList for folder support
|
||||||
|
const { items } = dataTransfer;
|
||||||
|
if (items) {
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.kind !== 'file') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
|
||||||
|
if (!entry) {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isFile) {
|
||||||
|
const file = await new Promise<File>((resolve, reject) => {
|
||||||
|
(entry as FileSystemFileEntry).file(resolve, reject);
|
||||||
|
});
|
||||||
|
files.push(file);
|
||||||
|
} else if (entry.isDirectory) {
|
||||||
|
// Pass the directory name as basePath so files include the folder path
|
||||||
|
const dirFiles = await readAllDirectoryEntries(entry as FileSystemDirectoryEntry, entry.name);
|
||||||
|
files.push(...dirFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for browsers that don't support webkitGetAsEntry
|
||||||
|
for (const file of Array.from(dataTransfer.files)) {
|
||||||
|
files.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
export const useFileTreeUpload = ({
|
export const useFileTreeUpload = ({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
@@ -65,20 +249,150 @@ export const useFileTreeUpload = ({
|
|||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||||
const [operationLoading, setOperationLoading] = useState(false);
|
const [operationLoading, setOperationLoading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<FileTreeUploadProgressState | null>(null);
|
||||||
const treeRef = useRef<HTMLDivElement>(null);
|
const treeRef = useRef<HTMLDivElement>(null);
|
||||||
|
const clearProgressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
const clearProgressTimer = useCallback(() => {
|
||||||
|
if (clearProgressTimerRef.current) {
|
||||||
|
clearTimeout(clearProgressTimerRef.current);
|
||||||
|
clearProgressTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleProgressClear = useCallback(
|
||||||
|
(delay: number) => {
|
||||||
|
clearProgressTimer();
|
||||||
|
clearProgressTimerRef.current = setTimeout(() => {
|
||||||
|
setUploadProgress(null);
|
||||||
|
clearProgressTimerRef.current = null;
|
||||||
|
}, delay);
|
||||||
|
},
|
||||||
|
[clearProgressTimer],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => clearProgressTimer, [clearProgressTimer]);
|
||||||
|
|
||||||
|
const setUploadError = useCallback(
|
||||||
|
(message: string, fileCount: number, targetPath = '', fileName?: string, progress = 0) => {
|
||||||
|
setUploadProgress({
|
||||||
|
status: 'error',
|
||||||
|
progress,
|
||||||
|
fileCount,
|
||||||
|
fileName,
|
||||||
|
targetPath,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
scheduleProgressClear(ERROR_PROGRESS_CLEAR_DELAY_MS);
|
||||||
|
},
|
||||||
|
[scheduleProgressClear],
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadFiles = useCallback(
|
||||||
|
async (files: File[], targetPath = '') => {
|
||||||
|
if (files.length === 0) {
|
||||||
|
setDropTarget(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = files.length === 1 ? getFileDisplayName(files[0]) : undefined;
|
||||||
|
|
||||||
|
if (!selectedProject) {
|
||||||
|
const message = 'Select a project before uploading files.';
|
||||||
|
showToast(message, 'error');
|
||||||
|
setUploadError(message, files.length, targetPath, fileName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationError = validateFilesForUpload(files);
|
||||||
|
if (validationError) {
|
||||||
|
showToast(validationError, 'error');
|
||||||
|
setUploadError(validationError, files.length, targetPath, fileName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearProgressTimer();
|
||||||
|
setOperationLoading(true);
|
||||||
|
setUploadProgress({
|
||||||
|
status: 'uploading',
|
||||||
|
progress: 0,
|
||||||
|
fileCount: files.length,
|
||||||
|
fileName,
|
||||||
|
targetPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
let latestProgress = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await uploadFormDataWithProgress(
|
||||||
|
selectedProject.projectId,
|
||||||
|
buildUploadFormData(files, targetPath),
|
||||||
|
(progress) => {
|
||||||
|
latestProgress = progress;
|
||||||
|
setUploadProgress((current) =>
|
||||||
|
current && current.status === 'uploading'
|
||||||
|
? { ...current, progress }
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadedCount =
|
||||||
|
typeof response.uploadedCount === 'number' ? response.uploadedCount : response.files?.length ?? files.length;
|
||||||
|
const requestedFileCount =
|
||||||
|
typeof response.requestedFileCount === 'number' ? response.requestedFileCount : files.length;
|
||||||
|
|
||||||
|
setUploadProgress({
|
||||||
|
status: 'complete',
|
||||||
|
progress: 100,
|
||||||
|
fileCount: requestedFileCount,
|
||||||
|
uploadedCount,
|
||||||
|
fileName,
|
||||||
|
targetPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
showToast(formatUploadSuccessMessage(uploadedCount, requestedFileCount), 'success');
|
||||||
|
scheduleProgressClear(COMPLETE_PROGRESS_CLEAR_DELAY_MS);
|
||||||
|
onRefresh();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Upload failed';
|
||||||
|
console.error('Upload error:', err);
|
||||||
|
showToast(message, 'error');
|
||||||
|
setUploadError(message, files.length, targetPath, fileName, latestProgress);
|
||||||
|
} finally {
|
||||||
|
setOperationLoading(false);
|
||||||
|
setDropTarget(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
clearProgressTimer,
|
||||||
|
onRefresh,
|
||||||
|
scheduleProgressClear,
|
||||||
|
selectedProject,
|
||||||
|
setUploadError,
|
||||||
|
showToast,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFileSelect = useCallback(
|
||||||
|
async (fileList: FileList | File[]) => {
|
||||||
|
await uploadFiles(Array.from(fileList), '');
|
||||||
|
},
|
||||||
|
[uploadFiles],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnter = useCallback((e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsDragOver(true);
|
setIsDragOver(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
const handleDragOver = useCallback((e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
const handleDragLeave = useCallback((e: DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// Only set isDragOver to false if we're leaving the entire tree
|
// Only set isDragOver to false if we're leaving the entire tree
|
||||||
@@ -88,103 +402,35 @@ export const useFileTreeUpload = ({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
const handleDrop = useCallback(
|
||||||
e.preventDefault();
|
async (e: DragEvent) => {
|
||||||
e.stopPropagation();
|
e.preventDefault();
|
||||||
setIsDragOver(false);
|
e.stopPropagation();
|
||||||
|
setIsDragOver(false);
|
||||||
|
|
||||||
const targetPath = dropTarget || '';
|
const targetPath = dropTarget || '';
|
||||||
setOperationLoading(true);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const files: File[] = [];
|
const files = await collectDroppedFiles(e.dataTransfer);
|
||||||
|
await uploadFiles(files, targetPath);
|
||||||
// Use DataTransferItemList for folder support
|
} catch (err) {
|
||||||
const items = e.dataTransfer.items;
|
const message = err instanceof Error ? err.message : 'Could not read dropped files';
|
||||||
if (items) {
|
console.error('Upload error:', err);
|
||||||
for (const item of Array.from(items)) {
|
showToast(message, 'error');
|
||||||
if (item.kind === 'file') {
|
setUploadError(message, 0, targetPath);
|
||||||
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
|
|
||||||
|
|
||||||
if (entry) {
|
|
||||||
if (entry.isFile) {
|
|
||||||
const file = await new Promise<File>((resolve, reject) => {
|
|
||||||
(entry as FileSystemFileEntry).file(resolve, reject);
|
|
||||||
});
|
|
||||||
files.push(file);
|
|
||||||
} else if (entry.isDirectory) {
|
|
||||||
// Pass the directory name as basePath so files include the folder path
|
|
||||||
const dirFiles = await readAllDirectoryEntries(entry as FileSystemDirectoryEntry, entry.name);
|
|
||||||
files.push(...dirFiles);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback for browsers that don't support webkitGetAsEntry
|
|
||||||
const fileList = e.dataTransfer.files;
|
|
||||||
for (const file of Array.from(fileList)) {
|
|
||||||
files.push(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (files.length === 0) {
|
|
||||||
setOperationLoading(false);
|
|
||||||
setDropTarget(null);
|
setDropTarget(null);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[dropTarget, setUploadError, showToast, uploadFiles],
|
||||||
|
);
|
||||||
|
|
||||||
const formData = new FormData();
|
const handleItemDragOver = useCallback((e: DragEvent, itemPath: string) => {
|
||||||
formData.append('targetPath', targetPath);
|
|
||||||
|
|
||||||
// Store relative paths separately since FormData strips path info from File.name
|
|
||||||
const relativePaths: string[] = [];
|
|
||||||
files.forEach((file) => {
|
|
||||||
// Create a new file with just the filename (without path) for FormData
|
|
||||||
// but store the relative path separately
|
|
||||||
const cleanFile = new File([file], file.name.split('/').pop()!, {
|
|
||||||
type: file.type,
|
|
||||||
lastModified: file.lastModified
|
|
||||||
});
|
|
||||||
formData.append('files', cleanFile);
|
|
||||||
relativePaths.push(file.name); // Keep the full relative path
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send relative paths as a JSON array
|
|
||||||
formData.append('relativePaths', JSON.stringify(relativePaths));
|
|
||||||
|
|
||||||
const response = await api.post(
|
|
||||||
// File upload endpoint is keyed by DB projectId post-migration.
|
|
||||||
`/projects/${encodeURIComponent(selectedProject!.projectId)}/files/upload`,
|
|
||||||
formData
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
throw new Error(data.error || 'Upload failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast(
|
|
||||||
`Uploaded ${files.length} file(s)`,
|
|
||||||
'success'
|
|
||||||
);
|
|
||||||
onRefresh();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Upload error:', err);
|
|
||||||
showToast(err instanceof Error ? err.message : 'Upload failed', 'error');
|
|
||||||
} finally {
|
|
||||||
setOperationLoading(false);
|
|
||||||
setDropTarget(null);
|
|
||||||
}
|
|
||||||
}, [dropTarget, selectedProject, onRefresh, showToast]);
|
|
||||||
|
|
||||||
const handleItemDragOver = useCallback((e: React.DragEvent, itemPath: string) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDropTarget(itemPath);
|
setDropTarget(itemPath);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleItemDrop = useCallback((e: React.DragEvent, itemPath: string) => {
|
const handleItemDrop = useCallback((e: DragEvent, itemPath: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDropTarget(itemPath);
|
setDropTarget(itemPath);
|
||||||
@@ -194,7 +440,9 @@ export const useFileTreeUpload = ({
|
|||||||
isDragOver,
|
isDragOver,
|
||||||
dropTarget,
|
dropTarget,
|
||||||
operationLoading,
|
operationLoading,
|
||||||
|
uploadProgress,
|
||||||
treeRef,
|
treeRef,
|
||||||
|
handleFileSelect,
|
||||||
handleDragEnter,
|
handleDragEnter,
|
||||||
handleDragOver,
|
handleDragOver,
|
||||||
handleDragLeave,
|
handleDragLeave,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useState, useEffect, useRef } from 'react';
|
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react';
|
import { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '../../../lib/utils';
|
import { cn } from '../../../lib/utils';
|
||||||
import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons';
|
import { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons';
|
||||||
import { useExpandedDirectories } from '../hooks/useExpandedDirectories';
|
import { useExpandedDirectories } from '../hooks/useExpandedDirectories';
|
||||||
@@ -13,10 +14,12 @@ import type { FileTreeImageSelection, FileTreeNode } from '../types/types';
|
|||||||
import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils';
|
import { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils';
|
||||||
import { Project } from '../../../types/app';
|
import { Project } from '../../../types/app';
|
||||||
import { ScrollArea, Input } from '../../../shared/view/ui';
|
import { ScrollArea, Input } from '../../../shared/view/ui';
|
||||||
|
|
||||||
import FileTreeBody from './FileTreeBody';
|
import FileTreeBody from './FileTreeBody';
|
||||||
import FileTreeDetailedColumns from './FileTreeDetailedColumns';
|
import FileTreeDetailedColumns from './FileTreeDetailedColumns';
|
||||||
import FileTreeHeader from './FileTreeHeader';
|
import FileTreeHeader from './FileTreeHeader';
|
||||||
import FileTreeLoadingState from './FileTreeLoadingState';
|
import FileTreeLoadingState from './FileTreeLoadingState';
|
||||||
|
import FileTreeUploadProgress from './FileTreeUploadProgress';
|
||||||
import ImageViewer from './ImageViewer';
|
import ImageViewer from './ImageViewer';
|
||||||
|
|
||||||
|
|
||||||
@@ -66,6 +69,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
|||||||
onRefresh: refreshFiles,
|
onRefresh: refreshFiles,
|
||||||
showToast,
|
showToast,
|
||||||
});
|
});
|
||||||
|
const operationLoading = operations.operationLoading || upload.operationLoading;
|
||||||
|
|
||||||
// Focus input when creating new item
|
// Focus input when creating new item
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,14 +150,19 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
|||||||
onViewModeChange={changeViewMode}
|
onViewModeChange={changeViewMode}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSearchQueryChange={setSearchQuery}
|
onSearchQueryChange={setSearchQuery}
|
||||||
|
onUploadFiles={upload.handleFileSelect}
|
||||||
onNewFile={() => operations.handleStartCreate('', 'file')}
|
onNewFile={() => operations.handleStartCreate('', 'file')}
|
||||||
onNewFolder={() => operations.handleStartCreate('', 'directory')}
|
onNewFolder={() => operations.handleStartCreate('', 'directory')}
|
||||||
onRefresh={refreshFiles}
|
onRefresh={refreshFiles}
|
||||||
onCollapseAll={collapseAll}
|
onCollapseAll={collapseAll}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
operationLoading={operations.operationLoading}
|
operationLoading={operationLoading}
|
||||||
|
isUploading={upload.uploadProgress?.status === 'uploading'}
|
||||||
|
uploadProgress={upload.uploadProgress?.progress ?? null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FileTreeUploadProgress upload={upload.uploadProgress} />
|
||||||
|
|
||||||
{viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}
|
{viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}
|
||||||
|
|
||||||
<ScrollArea className="flex-1 px-2 py-1">
|
<ScrollArea className="flex-1 px-2 py-1">
|
||||||
@@ -184,7 +193,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
|||||||
}, 100);
|
}, 100);
|
||||||
}}
|
}}
|
||||||
className="h-6 flex-1 text-sm"
|
className="h-6 flex-1 text-sm"
|
||||||
disabled={operations.operationLoading}
|
disabled={operationLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -213,7 +222,7 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
|||||||
handleConfirmRename={operations.handleConfirmRename}
|
handleConfirmRename={operations.handleConfirmRename}
|
||||||
handleCancelRename={operations.handleCancelRename}
|
handleCancelRename={operations.handleCancelRename}
|
||||||
renameInputRef={renameInputRef}
|
renameInputRef={renameInputRef}
|
||||||
operationLoading={operations.operationLoading}
|
operationLoading={operationLoading}
|
||||||
/>
|
/>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
@@ -251,17 +260,17 @@ export default function FileTree({ selectedProject, onFileOpen }: FileTreeProps)
|
|||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={operations.handleCancelDelete}
|
onClick={operations.handleCancelDelete}
|
||||||
disabled={operations.operationLoading}
|
disabled={operationLoading}
|
||||||
className="rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
className="rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
{t('common.cancel', 'Cancel')}
|
{t('common.cancel', 'Cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={operations.handleConfirmDelete}
|
onClick={operations.handleConfirmDelete}
|
||||||
disabled={operations.operationLoading}
|
disabled={operationLoading}
|
||||||
className="flex items-center gap-2 rounded-md bg-red-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-red-700 disabled:opacity-50"
|
className="flex items-center gap-2 rounded-md bg-red-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-red-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{operations.operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
{operationLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{t('fileTree.delete.confirm', 'Delete')}
|
{t('fileTree.delete.confirm', 'Delete')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, X } from 'lucide-react';
|
import { useRef } from 'react';
|
||||||
|
import type { ChangeEvent } from 'react';
|
||||||
|
import { ChevronDown, Eye, FileText, FolderPlus, List, Loader2, RefreshCw, Search, TableProperties, Upload, X } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Button, Input } from '../../../shared/view/ui';
|
import { Button, Input } from '../../../shared/view/ui';
|
||||||
import { cn } from '../../../lib/utils';
|
import { cn } from '../../../lib/utils';
|
||||||
|
import { MAX_FILE_UPLOAD_SIZE_LABEL } from '../constants/constants';
|
||||||
import type { FileTreeViewMode } from '../types/types';
|
import type { FileTreeViewMode } from '../types/types';
|
||||||
|
|
||||||
type FileTreeHeaderProps = {
|
type FileTreeHeaderProps = {
|
||||||
@@ -12,11 +16,14 @@ type FileTreeHeaderProps = {
|
|||||||
// Toolbar actions
|
// Toolbar actions
|
||||||
onNewFile?: () => void;
|
onNewFile?: () => void;
|
||||||
onNewFolder?: () => void;
|
onNewFolder?: () => void;
|
||||||
|
onUploadFiles?: (files: FileList) => void;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
onCollapseAll?: () => void;
|
onCollapseAll?: () => void;
|
||||||
// Loading state
|
// Loading state
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
operationLoading?: boolean;
|
operationLoading?: boolean;
|
||||||
|
isUploading?: boolean;
|
||||||
|
uploadProgress?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FileTreeHeader({
|
export default function FileTreeHeader({
|
||||||
@@ -26,12 +33,24 @@ export default function FileTreeHeader({
|
|||||||
onSearchQueryChange,
|
onSearchQueryChange,
|
||||||
onNewFile,
|
onNewFile,
|
||||||
onNewFolder,
|
onNewFolder,
|
||||||
|
onUploadFiles,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onCollapseAll,
|
onCollapseAll,
|
||||||
loading,
|
loading,
|
||||||
operationLoading,
|
operationLoading,
|
||||||
|
isUploading,
|
||||||
|
uploadProgress,
|
||||||
}: FileTreeHeaderProps) {
|
}: FileTreeHeaderProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleUploadInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { files } = event.target;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
onUploadFiles?.(files);
|
||||||
|
}
|
||||||
|
event.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 border-b border-border px-3 pb-2 pt-3">
|
<div className="space-y-2 border-b border-border px-3 pb-2 pt-3">
|
||||||
@@ -40,6 +59,50 @@ export default function FileTreeHeader({
|
|||||||
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
|
<h3 className="text-sm font-medium text-foreground">{t('fileTree.files')}</h3>
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
|
{onUploadFiles && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={uploadInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleUploadInputChange}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="relative h-7 w-7 p-0"
|
||||||
|
onClick={() => uploadInputRef.current?.click()}
|
||||||
|
title={
|
||||||
|
isUploading
|
||||||
|
? t('fileTree.uploadingFiles', 'Uploading files')
|
||||||
|
: t('fileTree.uploadFiles', 'Upload files (max {{size}} each)', {
|
||||||
|
size: MAX_FILE_UPLOAD_SIZE_LABEL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-label={t('fileTree.uploadFiles', 'Upload files (max {{size}} each)', {
|
||||||
|
size: MAX_FILE_UPLOAD_SIZE_LABEL,
|
||||||
|
})}
|
||||||
|
disabled={operationLoading}
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{isUploading && typeof uploadProgress === 'number' && (
|
||||||
|
<span className="absolute bottom-0.5 left-1/2 h-0.5 w-4 -translate-x-1/2 overflow-hidden rounded-full bg-primary/20">
|
||||||
|
<span
|
||||||
|
className="block h-full rounded-full bg-primary transition-[width] duration-150"
|
||||||
|
style={{ width: `${uploadProgress}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{onNewFile && (
|
{onNewFile && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
90
src/components/file-tree/view/FileTreeUploadProgress.tsx
Normal file
90
src/components/file-tree/view/FileTreeUploadProgress.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { AlertCircle, CheckCircle2, Upload } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { cn } from '../../../lib/utils';
|
||||||
|
import type { FileTreeUploadProgressState } from '../hooks/useFileTreeUpload';
|
||||||
|
|
||||||
|
type FileTreeUploadProgressProps = {
|
||||||
|
upload: FileTreeUploadProgressState | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clampProgress = (progress: number) => Math.min(100, Math.max(0, progress));
|
||||||
|
|
||||||
|
const pluralizeFiles = (count: number) => (count === 1 ? 'file' : 'files');
|
||||||
|
|
||||||
|
export default function FileTreeUploadProgress({ upload }: FileTreeUploadProgressProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!upload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = clampProgress(upload.progress);
|
||||||
|
const isUploading = upload.status === 'uploading';
|
||||||
|
const isComplete = upload.status === 'complete';
|
||||||
|
const isError = upload.status === 'error';
|
||||||
|
const fileSummary =
|
||||||
|
upload.fileCount === 1 && upload.fileName
|
||||||
|
? upload.fileName
|
||||||
|
: `${upload.fileCount} ${pluralizeFiles(upload.fileCount)}`;
|
||||||
|
|
||||||
|
const title = isUploading
|
||||||
|
? t('fileTree.uploadingFiles', 'Uploading files')
|
||||||
|
: isComplete
|
||||||
|
? t('fileTree.uploadComplete', 'Upload complete')
|
||||||
|
: t('fileTree.uploadFailed', 'Upload failed');
|
||||||
|
|
||||||
|
const detail = isError
|
||||||
|
? upload.error
|
||||||
|
: isComplete && typeof upload.uploadedCount === 'number'
|
||||||
|
? t('fileTree.uploadedCount', 'Uploaded {{uploaded}} of {{total}} {{label}}', {
|
||||||
|
uploaded: upload.uploadedCount,
|
||||||
|
total: upload.fileCount,
|
||||||
|
label: pluralizeFiles(upload.fileCount),
|
||||||
|
})
|
||||||
|
: fileSummary;
|
||||||
|
|
||||||
|
const Icon = isError ? AlertCircle : isComplete ? CheckCircle2 : Upload;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'border-b px-3 py-2 transition-colors',
|
||||||
|
isError
|
||||||
|
? 'border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300'
|
||||||
|
: isComplete
|
||||||
|
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
|
||||||
|
: 'border-primary/20 bg-primary/10 text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-h-[36px] items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-7 w-7 shrink-0 items-center justify-center rounded-md',
|
||||||
|
isError ? 'bg-red-500/15' : isComplete ? 'bg-emerald-500/15' : 'bg-primary/15',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn('h-3.5 w-3.5', isUploading && 'animate-pulse')} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="truncate text-xs font-medium">{title}</span>
|
||||||
|
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||||
|
{isUploading ? `${progress}%` : isComplete ? t('common.done', 'Done') : t('common.failed', 'Failed')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 truncate text-[11px] text-muted-foreground">{detail}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-background/80">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full rounded-full transition-[width] duration-200',
|
||||||
|
isError ? 'bg-red-500' : isComplete ? 'bg-emerald-500' : 'bg-primary',
|
||||||
|
)}
|
||||||
|
style={{ width: `${isError ? Math.max(progress, 8) : progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-start
|
|||||||
const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-terminal';
|
const TERMINAL_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-terminal';
|
||||||
const SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron';
|
const SCHEDULED_PROMPT_PLUGIN_URL = 'https://github.com/grostim/cloudcli-cron';
|
||||||
const CLAUDE_WATCH_PLUGIN_URL = 'https://github.com/satsuki19980613/cloudcli-claude-watch';
|
const CLAUDE_WATCH_PLUGIN_URL = 'https://github.com/satsuki19980613/cloudcli-claude-watch';
|
||||||
|
const PRISM_CLOUDCLI_PLUGIN_URL = 'https://github.com/jakeefr/cloudcli-plugin-prism';
|
||||||
|
|
||||||
type PluginRecommendation = {
|
type PluginRecommendation = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -72,6 +73,14 @@ const UNOFFICIAL_PLUGIN_RECOMMENDATIONS: PluginRecommendation[] = [
|
|||||||
icon: Clock,
|
icon: Clock,
|
||||||
source: 'unofficial',
|
source: 'unofficial',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'prism',
|
||||||
|
translationKey: 'prismCloudCLI',
|
||||||
|
repoUrl: PRISM_CLOUDCLI_PLUGIN_URL,
|
||||||
|
installedNames: ['prism'],
|
||||||
|
icon: Activity,
|
||||||
|
source: 'unofficial'
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
function repoSlug(repoUrl: string) {
|
function repoSlug(repoUrl: string) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { authenticatedFetch } from '../../../utils/api';
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
|
import { setNotificationSoundEnabled } from '../../../utils/notificationSound';
|
||||||
import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus';
|
import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus';
|
||||||
import {
|
import {
|
||||||
DEFAULT_CODE_EDITOR_SETTINGS,
|
DEFAULT_CODE_EDITOR_SETTINGS,
|
||||||
@@ -107,6 +109,7 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: true,
|
inApp: true,
|
||||||
webPush: false,
|
webPush: false,
|
||||||
|
sound: true,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
actionRequired: true,
|
actionRequired: true,
|
||||||
@@ -115,6 +118,25 @@ const createDefaultNotificationPreferences = (): NotificationPreferencesState =>
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const normalizeNotificationPreferences = (
|
||||||
|
preferences?: Partial<NotificationPreferencesState> | null,
|
||||||
|
): NotificationPreferencesState => {
|
||||||
|
const defaults = createDefaultNotificationPreferences();
|
||||||
|
|
||||||
|
return {
|
||||||
|
channels: {
|
||||||
|
inApp: preferences?.channels?.inApp ?? defaults.channels.inApp,
|
||||||
|
webPush: preferences?.channels?.webPush ?? defaults.channels.webPush,
|
||||||
|
sound: preferences?.channels?.sound ?? defaults.channels.sound,
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
actionRequired: preferences?.events?.actionRequired ?? defaults.events.actionRequired,
|
||||||
|
stop: preferences?.events?.stop ?? defaults.events.stop,
|
||||||
|
error: preferences?.events?.error ?? defaults.events.error,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export function useSettingsController({ isOpen, initialTab }: UseSettingsControllerArgs) {
|
export function useSettingsController({ isOpen, initialTab }: UseSettingsControllerArgs) {
|
||||||
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
|
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
|
||||||
const closeTimerRef = useRef<number | null>(null);
|
const closeTimerRef = useRef<number | null>(null);
|
||||||
@@ -186,7 +208,7 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
if (notificationResponse.ok) {
|
if (notificationResponse.ok) {
|
||||||
const notificationData = await toResponseJson<NotificationPreferencesResponse>(notificationResponse);
|
const notificationData = await toResponseJson<NotificationPreferencesResponse>(notificationResponse);
|
||||||
if (notificationData.success && notificationData.preferences) {
|
if (notificationData.success && notificationData.preferences) {
|
||||||
setNotificationPreferences(notificationData.preferences);
|
setNotificationPreferences(normalizeNotificationPreferences(notificationData.preferences));
|
||||||
} else {
|
} else {
|
||||||
setNotificationPreferences(createDefaultNotificationPreferences());
|
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||||
}
|
}
|
||||||
@@ -301,6 +323,10 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
void refreshProviderAuthStatuses();
|
void refreshProviderAuthStatuses();
|
||||||
}, [initialTab, isOpen, loadSettings, refreshProviderAuthStatuses]);
|
}, [initialTab, isOpen, loadSettings, refreshProviderAuthStatuses]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNotificationSoundEnabled(notificationPreferences.channels.sound);
|
||||||
|
}, [notificationPreferences.channels.sound]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
|
localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
|
||||||
localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));
|
localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Dispatch, SetStateAction } from 'react';
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
|
||||||
import type { LLMProvider } from '../../../types/app';
|
import type { LLMProvider } from '../../../types/app';
|
||||||
import type { ProviderAuthStatus } from '../../provider-auth/types';
|
import type { ProviderAuthStatus } from '../../provider-auth/types';
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export type NotificationPreferencesState = {
|
|||||||
channels: {
|
channels: {
|
||||||
inApp: boolean;
|
inApp: boolean;
|
||||||
webPush: boolean;
|
webPush: boolean;
|
||||||
|
sound: boolean;
|
||||||
};
|
};
|
||||||
events: {
|
events: {
|
||||||
actionRequired: boolean;
|
actionRequired: boolean;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { Bell, BellOff, BellRing, Loader2 } from 'lucide-react';
|
import { Bell, BellOff, BellRing, Loader2, Play, Volume2 } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Button } from '../../../../shared/view/ui';
|
||||||
|
import { playChatCompletionSound } from '../../../../utils/notificationSound';
|
||||||
import type { NotificationPreferencesState } from '../../types/types';
|
import type { NotificationPreferencesState } from '../../types/types';
|
||||||
|
|
||||||
type NotificationsSettingsTabProps = {
|
type NotificationsSettingsTabProps = {
|
||||||
@@ -82,6 +85,54 @@ export default function NotificationsSettingsTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Volume2 className="h-4 w-4 text-blue-600" />
|
||||||
|
<h4 className="font-medium text-foreground">
|
||||||
|
{t('notifications.sound.title', { defaultValue: 'Sound' })}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('notifications.sound.description', {
|
||||||
|
defaultValue: 'Play a short tone when a chat run finishes.',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex shrink-0 items-center gap-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={notificationPreferences.channels.sound}
|
||||||
|
onChange={(event) =>
|
||||||
|
onNotificationPreferencesChange({
|
||||||
|
...notificationPreferences,
|
||||||
|
channels: {
|
||||||
|
...notificationPreferences.channels,
|
||||||
|
sound: event.target.checked,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
{t('notifications.sound.enabled', { defaultValue: 'Enabled' })}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
void playChatCompletionSound({ force: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
{t('notifications.sound.test', { defaultValue: 'Test sound' })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
<div className="space-y-4 bg-card border border-border rounded-lg p-4">
|
||||||
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
|
<h4 className="font-medium text-foreground">{t('notifications.events.title')}</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ import itCodeEditor from './locales/it/codeEditor.json';
|
|||||||
// eslint-disable-next-line import-x/order
|
// eslint-disable-next-line import-x/order
|
||||||
import itTasks from './locales/it/tasks.json';
|
import itTasks from './locales/it/tasks.json';
|
||||||
|
|
||||||
|
import zhTWCommon from './locales/zh-TW/common.json';
|
||||||
|
import zhTWSettings from './locales/zh-TW/settings.json';
|
||||||
|
import zhTWAuth from './locales/zh-TW/auth.json';
|
||||||
|
import zhTWSidebar from './locales/zh-TW/sidebar.json';
|
||||||
|
import zhTWChat from './locales/zh-TW/chat.json';
|
||||||
|
import zhTWCodeEditor from './locales/zh-TW/codeEditor.json';
|
||||||
|
// eslint-disable-next-line import-x/order
|
||||||
|
import zhTWTasks from './locales/zh-TW/tasks.json';
|
||||||
|
|
||||||
// Import supported languages configuration
|
// Import supported languages configuration
|
||||||
import { languages } from './languages.js';
|
import { languages } from './languages.js';
|
||||||
|
|
||||||
@@ -178,6 +187,15 @@ i18n
|
|||||||
codeEditor: itCodeEditor,
|
codeEditor: itCodeEditor,
|
||||||
tasks: itTasks,
|
tasks: itTasks,
|
||||||
},
|
},
|
||||||
|
'zh-TW': {
|
||||||
|
common: zhTWCommon,
|
||||||
|
settings: zhTWSettings,
|
||||||
|
auth: zhTWAuth,
|
||||||
|
sidebar: zhTWSidebar,
|
||||||
|
chat: zhTWChat,
|
||||||
|
codeEditor: zhTWCodeEditor,
|
||||||
|
tasks: zhTWTasks,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Default language
|
// Default language
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export const languages = [
|
|||||||
label: 'Simplified Chinese',
|
label: 'Simplified Chinese',
|
||||||
nativeName: '简体中文',
|
nativeName: '简体中文',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'zh-TW',
|
||||||
|
label: 'Traditional Chinese',
|
||||||
|
nativeName: '繁體中文',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: 'ja',
|
value: 'ja',
|
||||||
label: 'Japanese',
|
label: 'Japanese',
|
||||||
|
|||||||
@@ -138,42 +138,6 @@
|
|||||||
"clearInput": "Eingabe leeren",
|
"clearInput": "Eingabe leeren",
|
||||||
"scrollToBottom": "Nach unten scrollen"
|
"scrollToBottom": "Nach unten scrollen"
|
||||||
},
|
},
|
||||||
"thinkingMode": {
|
|
||||||
"selector": {
|
|
||||||
"title": "Denkmodus",
|
|
||||||
"description": "Erweitertes Denken gibt Claude mehr Zeit, Alternativen zu evaluieren",
|
|
||||||
"active": "Aktiv",
|
|
||||||
"tip": "Höhere Denkmodi brauchen mehr Zeit, liefern aber eine gründlichere Analyse"
|
|
||||||
},
|
|
||||||
"modes": {
|
|
||||||
"none": {
|
|
||||||
"name": "Standard",
|
|
||||||
"description": "Reguläre Claude-Antwort",
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"think": {
|
|
||||||
"name": "Denken",
|
|
||||||
"description": "Grundlegendes erweitertes Denken",
|
|
||||||
"prefix": "think"
|
|
||||||
},
|
|
||||||
"thinkHard": {
|
|
||||||
"name": "Intensiv denken",
|
|
||||||
"description": "Gründlichere Auswertung",
|
|
||||||
"prefix": "think hard"
|
|
||||||
},
|
|
||||||
"thinkHarder": {
|
|
||||||
"name": "Sehr intensiv denken",
|
|
||||||
"description": "Tiefgehende Analyse mit Alternativen",
|
|
||||||
"prefix": "think harder"
|
|
||||||
},
|
|
||||||
"ultrathink": {
|
|
||||||
"name": "Ultradenken",
|
|
||||||
"description": "Maximales Denkbudget",
|
|
||||||
"prefix": "ultrathink"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"buttonTitle": "Denkmodus: {{mode}}"
|
|
||||||
},
|
|
||||||
"providerSelection": {
|
"providerSelection": {
|
||||||
"title": "KI-Assistent wählen",
|
"title": "KI-Assistent wählen",
|
||||||
"description": "Anbieter auswählen, um eine neue Unterhaltung zu starten",
|
"description": "Anbieter auswählen, um eine neue Unterhaltung zu starten",
|
||||||
|
|||||||
@@ -94,9 +94,35 @@
|
|||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API & Token",
|
"apiTokens": "API & Token",
|
||||||
"tasks": "Aufgaben",
|
"tasks": "Aufgaben",
|
||||||
|
"notifications": "Benachrichtigungen",
|
||||||
"plugins": "Plugins",
|
"plugins": "Plugins",
|
||||||
"about": "Info"
|
"about": "Info"
|
||||||
},
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Benachrichtigungen",
|
||||||
|
"description": "Lege fest, welche Benachrichtigungen du erhältst.",
|
||||||
|
"webPush": {
|
||||||
|
"title": "Web-Push-Benachrichtigungen",
|
||||||
|
"enable": "Push-Benachrichtigungen aktivieren",
|
||||||
|
"disable": "Push-Benachrichtigungen deaktivieren",
|
||||||
|
"enabled": "Push-Benachrichtigungen sind aktiviert",
|
||||||
|
"loading": "Wird aktualisiert...",
|
||||||
|
"unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
|
||||||
|
"denied": "Push-Benachrichtigungen sind blockiert. Bitte erlaube sie in den Browsereinstellungen."
|
||||||
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "Ton",
|
||||||
|
"description": "Spielt einen kurzen Ton ab, wenn ein Chat-Lauf abgeschlossen ist.",
|
||||||
|
"enabled": "Aktiviert",
|
||||||
|
"test": "Ton testen"
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "Ereignistypen",
|
||||||
|
"actionRequired": "Aktion erforderlich",
|
||||||
|
"stop": "Lauf gestoppt",
|
||||||
|
"error": "Lauf fehlgeschlagen"
|
||||||
|
}
|
||||||
|
},
|
||||||
"appearanceSettings": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
"label": "Darkmode",
|
"label": "Darkmode",
|
||||||
|
|||||||
@@ -139,42 +139,6 @@
|
|||||||
"clearInput": "Clear input",
|
"clearInput": "Clear input",
|
||||||
"scrollToBottom": "Scroll to bottom"
|
"scrollToBottom": "Scroll to bottom"
|
||||||
},
|
},
|
||||||
"thinkingMode": {
|
|
||||||
"selector": {
|
|
||||||
"title": "Thinking Mode",
|
|
||||||
"description": "Extended thinking gives Claude more time to evaluate alternatives",
|
|
||||||
"active": "Active",
|
|
||||||
"tip": "Higher thinking modes take more time but provide more thorough analysis"
|
|
||||||
},
|
|
||||||
"modes": {
|
|
||||||
"none": {
|
|
||||||
"name": "Standard",
|
|
||||||
"description": "Regular Claude response",
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"think": {
|
|
||||||
"name": "Think",
|
|
||||||
"description": "Basic extended thinking",
|
|
||||||
"prefix": "think"
|
|
||||||
},
|
|
||||||
"thinkHard": {
|
|
||||||
"name": "Think Hard",
|
|
||||||
"description": "More thorough evaluation",
|
|
||||||
"prefix": "think hard"
|
|
||||||
},
|
|
||||||
"thinkHarder": {
|
|
||||||
"name": "Think Harder",
|
|
||||||
"description": "Deep analysis with alternatives",
|
|
||||||
"prefix": "think harder"
|
|
||||||
},
|
|
||||||
"ultrathink": {
|
|
||||||
"name": "Ultrathink",
|
|
||||||
"description": "Maximum thinking budget",
|
|
||||||
"prefix": "ultrathink"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"buttonTitle": "Thinking mode: {{mode}}"
|
|
||||||
},
|
|
||||||
"providerSelection": {
|
"providerSelection": {
|
||||||
"title": "Choose Your AI Assistant",
|
"title": "Choose Your AI Assistant",
|
||||||
"description": "Select a provider to start a new conversation",
|
"description": "Select a provider to start a new conversation",
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
"unsupported": "Push notifications are not supported in this browser.",
|
"unsupported": "Push notifications are not supported in this browser.",
|
||||||
"denied": "Push notifications are blocked. Please allow them in your browser settings."
|
"denied": "Push notifications are blocked. Please allow them in your browser settings."
|
||||||
},
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "Sound",
|
||||||
|
"description": "Play a short tone when a chat run finishes.",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"test": "Test sound"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Event Types",
|
"title": "Event Types",
|
||||||
"actionRequired": "Action required",
|
"actionRequired": "Action required",
|
||||||
@@ -502,6 +508,12 @@
|
|||||||
"description": "Watch long-running Claude Code sessions for hangs and expose process controls.",
|
"description": "Watch long-running Claude Code sessions for hangs and expose process controls.",
|
||||||
"install": "Install"
|
"install": "Install"
|
||||||
},
|
},
|
||||||
|
"prismCloudCLI": {
|
||||||
|
"name": "PRISM CloudCLI",
|
||||||
|
"badge": "unofficial",
|
||||||
|
"description": "Session intelligence for Claude Code, inside CloudCLI. See why your sessions are burning tokens without leaving the browser.",
|
||||||
|
"install": "Install"
|
||||||
|
},
|
||||||
"morePlugins": "More",
|
"morePlugins": "More",
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
|
|||||||
@@ -138,42 +138,6 @@
|
|||||||
"clearInput": "Cancella input",
|
"clearInput": "Cancella input",
|
||||||
"scrollToBottom": "Scorri in basso"
|
"scrollToBottom": "Scorri in basso"
|
||||||
},
|
},
|
||||||
"thinkingMode": {
|
|
||||||
"selector": {
|
|
||||||
"title": "Modalità ragionamento",
|
|
||||||
"description": "Il ragionamento esteso dà a Claude più tempo per valutare le alternative",
|
|
||||||
"active": "Attivo",
|
|
||||||
"tip": "Modalità di ragionamento più elevate richiedono più tempo ma forniscono un'analisi più approfondita"
|
|
||||||
},
|
|
||||||
"modes": {
|
|
||||||
"none": {
|
|
||||||
"name": "Standard",
|
|
||||||
"description": "Risposta Claude normale",
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"think": {
|
|
||||||
"name": "Pensa",
|
|
||||||
"description": "Ragionamento esteso base",
|
|
||||||
"prefix": "think"
|
|
||||||
},
|
|
||||||
"thinkHard": {
|
|
||||||
"name": "Pensa di più",
|
|
||||||
"description": "Valutazione più approfondita",
|
|
||||||
"prefix": "think hard"
|
|
||||||
},
|
|
||||||
"thinkHarder": {
|
|
||||||
"name": "Pensa ancora",
|
|
||||||
"description": "Analisi profonda con alternative",
|
|
||||||
"prefix": "think harder"
|
|
||||||
},
|
|
||||||
"ultrathink": {
|
|
||||||
"name": "Ultrapensiero",
|
|
||||||
"description": "Budget massimo di ragionamento",
|
|
||||||
"prefix": "ultrathink"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"buttonTitle": "Modalità ragionamento: {{mode}}"
|
|
||||||
},
|
|
||||||
"providerSelection": {
|
"providerSelection": {
|
||||||
"title": "Scegli il tuo assistente AI",
|
"title": "Scegli il tuo assistente AI",
|
||||||
"description": "Seleziona un provider per iniziare una nuova conversazione",
|
"description": "Seleziona un provider per iniziare una nuova conversazione",
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
"unsupported": "Le notifiche push non sono supportate in questo browser.",
|
"unsupported": "Le notifiche push non sono supportate in questo browser.",
|
||||||
"denied": "Le notifiche push sono bloccate. Abilitale nelle impostazioni del browser."
|
"denied": "Le notifiche push sono bloccate. Abilitale nelle impostazioni del browser."
|
||||||
},
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "Suono",
|
||||||
|
"description": "Riproduci un breve tono quando termina un'esecuzione della chat.",
|
||||||
|
"enabled": "Attivato",
|
||||||
|
"test": "Prova suono"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Tipi di evento",
|
"title": "Tipi di evento",
|
||||||
"actionRequired": "Azione richiesta",
|
"actionRequired": "Azione richiesta",
|
||||||
|
|||||||
@@ -117,42 +117,6 @@
|
|||||||
"clickToChangeMode": "クリックで権限モードを変更(または入力欄でTab)",
|
"clickToChangeMode": "クリックで権限モードを変更(または入力欄でTab)",
|
||||||
"showAllCommands": "すべてのコマンドを表示"
|
"showAllCommands": "すべてのコマンドを表示"
|
||||||
},
|
},
|
||||||
"thinkingMode": {
|
|
||||||
"selector": {
|
|
||||||
"title": "思考モード",
|
|
||||||
"description": "拡張思考によりClaudeがより多くの選択肢を検討できます",
|
|
||||||
"active": "有効",
|
|
||||||
"tip": "高い思考モードは時間がかかりますが、より深い分析が得られます"
|
|
||||||
},
|
|
||||||
"modes": {
|
|
||||||
"none": {
|
|
||||||
"name": "標準",
|
|
||||||
"description": "通常のClaudeの応答",
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"think": {
|
|
||||||
"name": "Think",
|
|
||||||
"description": "基本的な拡張思考",
|
|
||||||
"prefix": "think"
|
|
||||||
},
|
|
||||||
"thinkHard": {
|
|
||||||
"name": "Think Hard",
|
|
||||||
"description": "より深い検討",
|
|
||||||
"prefix": "think hard"
|
|
||||||
},
|
|
||||||
"thinkHarder": {
|
|
||||||
"name": "Think Harder",
|
|
||||||
"description": "代替案を含む深い分析",
|
|
||||||
"prefix": "think harder"
|
|
||||||
},
|
|
||||||
"ultrathink": {
|
|
||||||
"name": "Ultrathink",
|
|
||||||
"description": "最大限の思考予算",
|
|
||||||
"prefix": "ultrathink"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"buttonTitle": "思考モード: {{mode}}"
|
|
||||||
},
|
|
||||||
"providerSelection": {
|
"providerSelection": {
|
||||||
"title": "AIアシスタントを選択",
|
"title": "AIアシスタントを選択",
|
||||||
"description": "新しい会話を始めるプロバイダーを選択してください",
|
"description": "新しい会話を始めるプロバイダーを選択してください",
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
"unsupported": "このブラウザではプッシュ通知がサポートされていません。",
|
"unsupported": "このブラウザではプッシュ通知がサポートされていません。",
|
||||||
"denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。"
|
"denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。"
|
||||||
},
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "サウンド",
|
||||||
|
"description": "チャット実行が完了したときに短い音を再生します。",
|
||||||
|
"enabled": "有効",
|
||||||
|
"test": "サウンドをテスト"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "イベント種別",
|
"title": "イベント種別",
|
||||||
"actionRequired": "対応が必要",
|
"actionRequired": "対応が必要",
|
||||||
|
|||||||
@@ -120,42 +120,6 @@
|
|||||||
"clearInput": "입력 지우기",
|
"clearInput": "입력 지우기",
|
||||||
"scrollToBottom": "맨 아래로 스크롤"
|
"scrollToBottom": "맨 아래로 스크롤"
|
||||||
},
|
},
|
||||||
"thinkingMode": {
|
|
||||||
"selector": {
|
|
||||||
"title": "Thinking 모드",
|
|
||||||
"description": "확장된 thinking은 Claude에게 대안을 평가할 시간을 더 줍니다",
|
|
||||||
"active": "활성",
|
|
||||||
"tip": "높은 thinking 모드는 시간이 더 걸리지만 더 철저한 분석을 제공합니다"
|
|
||||||
},
|
|
||||||
"modes": {
|
|
||||||
"none": {
|
|
||||||
"name": "Standard",
|
|
||||||
"description": "일반 Claude 응답",
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"think": {
|
|
||||||
"name": "Think",
|
|
||||||
"description": "기본 확장 thinking",
|
|
||||||
"prefix": "think"
|
|
||||||
},
|
|
||||||
"thinkHard": {
|
|
||||||
"name": "Think Hard",
|
|
||||||
"description": "더 철저한 평가",
|
|
||||||
"prefix": "think hard"
|
|
||||||
},
|
|
||||||
"thinkHarder": {
|
|
||||||
"name": "Think Harder",
|
|
||||||
"description": "대안을 포함한 심층 분석",
|
|
||||||
"prefix": "think harder"
|
|
||||||
},
|
|
||||||
"ultrathink": {
|
|
||||||
"name": "Ultrathink",
|
|
||||||
"description": "최대 thinking 예산",
|
|
||||||
"prefix": "ultrathink"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"buttonTitle": "Thinking 모드: {{mode}}"
|
|
||||||
},
|
|
||||||
"providerSelection": {
|
"providerSelection": {
|
||||||
"title": "AI 어시스턴트 선택",
|
"title": "AI 어시스턴트 선택",
|
||||||
"description": "새 대화를 시작할 프로바이더를 선택하세요",
|
"description": "새 대화를 시작할 프로바이더를 선택하세요",
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
"unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.",
|
"unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.",
|
||||||
"denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요."
|
"denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요."
|
||||||
},
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "소리",
|
||||||
|
"description": "채팅 실행이 완료되면 짧은 알림음을 재생합니다.",
|
||||||
|
"enabled": "사용",
|
||||||
|
"test": "소리 테스트"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "이벤트 유형",
|
"title": "이벤트 유형",
|
||||||
"actionRequired": "작업 필요",
|
"actionRequired": "작업 필요",
|
||||||
|
|||||||
@@ -138,42 +138,6 @@
|
|||||||
"clearInput": "Очистить ввод",
|
"clearInput": "Очистить ввод",
|
||||||
"scrollToBottom": "Прокрутить вниз"
|
"scrollToBottom": "Прокрутить вниз"
|
||||||
},
|
},
|
||||||
"thinkingMode": {
|
|
||||||
"selector": {
|
|
||||||
"title": "Режим размышления",
|
|
||||||
"description": "Расширенное размышление дает Claude больше времени для оценки альтернатив",
|
|
||||||
"active": "Активен",
|
|
||||||
"tip": "Более высокие режимы размышления занимают больше времени, но обеспечивают более тщательный анализ"
|
|
||||||
},
|
|
||||||
"modes": {
|
|
||||||
"none": {
|
|
||||||
"name": "Стандартный",
|
|
||||||
"description": "Обычный ответ Claude",
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"think": {
|
|
||||||
"name": "Думать",
|
|
||||||
"description": "Базовое расширенное размышление",
|
|
||||||
"prefix": "думать"
|
|
||||||
},
|
|
||||||
"thinkHard": {
|
|
||||||
"name": "Думать усердно",
|
|
||||||
"description": "Более тщательная оценка",
|
|
||||||
"prefix": "думать усердно"
|
|
||||||
},
|
|
||||||
"thinkHarder": {
|
|
||||||
"name": "Думать еще усерднее",
|
|
||||||
"description": "Глубокий анализ с альтернативами",
|
|
||||||
"prefix": "думать еще усерднее"
|
|
||||||
},
|
|
||||||
"ultrathink": {
|
|
||||||
"name": "Ультра-размышление",
|
|
||||||
"description": "Максимальный бюджет размышления",
|
|
||||||
"prefix": "ультра-размышление"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"buttonTitle": "Режим размышления: {{mode}}"
|
|
||||||
},
|
|
||||||
"providerSelection": {
|
"providerSelection": {
|
||||||
"title": "Выберите вашего AI-ассистента",
|
"title": "Выберите вашего AI-ассистента",
|
||||||
"description": "Выберите провайдера для начала нового разговора",
|
"description": "Выберите провайдера для начала нового разговора",
|
||||||
|
|||||||
@@ -94,9 +94,35 @@
|
|||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API и токены",
|
"apiTokens": "API и токены",
|
||||||
"tasks": "Задачи",
|
"tasks": "Задачи",
|
||||||
|
"notifications": "Уведомления",
|
||||||
"plugins": "Плагины",
|
"plugins": "Плагины",
|
||||||
"about": "О программе"
|
"about": "О программе"
|
||||||
},
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Уведомления",
|
||||||
|
"description": "Управляйте тем, какие события уведомлений вы получаете.",
|
||||||
|
"webPush": {
|
||||||
|
"title": "Web Push уведомления",
|
||||||
|
"enable": "Включить Push уведомления",
|
||||||
|
"disable": "Отключить Push уведомления",
|
||||||
|
"enabled": "Push уведомления включены",
|
||||||
|
"loading": "Обновление...",
|
||||||
|
"unsupported": "Push уведомления не поддерживаются в этом браузере.",
|
||||||
|
"denied": "Push уведомления заблокированы. Разрешите их в настройках браузера."
|
||||||
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "Звук",
|
||||||
|
"description": "Воспроизводить короткий сигнал при завершении запуска чата.",
|
||||||
|
"enabled": "Включено",
|
||||||
|
"test": "Проверить звук"
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "Типы событий",
|
||||||
|
"actionRequired": "Требуется действие",
|
||||||
|
"stop": "Запуск остановлен",
|
||||||
|
"error": "Запуск завершился с ошибкой"
|
||||||
|
}
|
||||||
|
},
|
||||||
"appearanceSettings": {
|
"appearanceSettings": {
|
||||||
"darkMode": {
|
"darkMode": {
|
||||||
"label": "Темная тема",
|
"label": "Темная тема",
|
||||||
|
|||||||
@@ -138,42 +138,6 @@
|
|||||||
"clearInput": "Girdiyi temizle",
|
"clearInput": "Girdiyi temizle",
|
||||||
"scrollToBottom": "En alta git"
|
"scrollToBottom": "En alta git"
|
||||||
},
|
},
|
||||||
"thinkingMode": {
|
|
||||||
"selector": {
|
|
||||||
"title": "Düşünme Modu",
|
|
||||||
"description": "Uzatılmış düşünme, Claude'a alternatifleri değerlendirmek için daha fazla zaman verir",
|
|
||||||
"active": "Aktif",
|
|
||||||
"tip": "Daha yüksek düşünme modları daha fazla zaman alır ama daha kapsamlı analiz sağlar"
|
|
||||||
},
|
|
||||||
"modes": {
|
|
||||||
"none": {
|
|
||||||
"name": "Standart",
|
|
||||||
"description": "Normal Claude yanıtı",
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"think": {
|
|
||||||
"name": "Düşün",
|
|
||||||
"description": "Temel uzatılmış düşünme",
|
|
||||||
"prefix": "think"
|
|
||||||
},
|
|
||||||
"thinkHard": {
|
|
||||||
"name": "Daha Fazla Düşün",
|
|
||||||
"description": "Daha kapsamlı değerlendirme",
|
|
||||||
"prefix": "think hard"
|
|
||||||
},
|
|
||||||
"thinkHarder": {
|
|
||||||
"name": "Derin Düşün",
|
|
||||||
"description": "Alternatiflerle derin analiz",
|
|
||||||
"prefix": "think harder"
|
|
||||||
},
|
|
||||||
"ultrathink": {
|
|
||||||
"name": "Ultra Düşün",
|
|
||||||
"description": "Maksimum düşünme bütçesi",
|
|
||||||
"prefix": "ultrathink"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"buttonTitle": "Düşünme modu: {{mode}}"
|
|
||||||
},
|
|
||||||
"providerSelection": {
|
"providerSelection": {
|
||||||
"title": "AI Asistanını Seç",
|
"title": "AI Asistanını Seç",
|
||||||
"description": "Yeni bir konuşma başlatmak için bir sağlayıcı seç",
|
"description": "Yeni bir konuşma başlatmak için bir sağlayıcı seç",
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
"unsupported": "Bu tarayıcıda push bildirimleri desteklenmiyor.",
|
"unsupported": "Bu tarayıcıda push bildirimleri desteklenmiyor.",
|
||||||
"denied": "Push bildirimleri engellendi. Lütfen tarayıcı ayarlarından izin ver."
|
"denied": "Push bildirimleri engellendi. Lütfen tarayıcı ayarlarından izin ver."
|
||||||
},
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "Ses",
|
||||||
|
"description": "Sohbet çalışması tamamlandığında kısa bir ton çal.",
|
||||||
|
"enabled": "Etkin",
|
||||||
|
"test": "Sesi test et"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Etkinlik Türleri",
|
"title": "Etkinlik Türleri",
|
||||||
"actionRequired": "Aksiyon gerekli",
|
"actionRequired": "Aksiyon gerekli",
|
||||||
|
|||||||
@@ -120,42 +120,6 @@
|
|||||||
"clearInput": "清空输入",
|
"clearInput": "清空输入",
|
||||||
"scrollToBottom": "滚动到底部"
|
"scrollToBottom": "滚动到底部"
|
||||||
},
|
},
|
||||||
"thinkingMode": {
|
|
||||||
"selector": {
|
|
||||||
"title": "思考模式",
|
|
||||||
"description": "扩展思考给 Claude 更多时间来评估替代方案",
|
|
||||||
"active": "激活",
|
|
||||||
"tip": "更高的思考模式需要更多时间,但提供更彻底的分析"
|
|
||||||
},
|
|
||||||
"modes": {
|
|
||||||
"none": {
|
|
||||||
"name": "标准",
|
|
||||||
"description": "常规 Claude 响应",
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"think": {
|
|
||||||
"name": "思考",
|
|
||||||
"description": "基本扩展思考",
|
|
||||||
"prefix": "思考"
|
|
||||||
},
|
|
||||||
"thinkHard": {
|
|
||||||
"name": "深入思考",
|
|
||||||
"description": "更彻底的评估",
|
|
||||||
"prefix": "深入思考"
|
|
||||||
},
|
|
||||||
"thinkHarder": {
|
|
||||||
"name": "更深入思考",
|
|
||||||
"description": "考虑替代方案的深度分析",
|
|
||||||
"prefix": "更深入思考"
|
|
||||||
},
|
|
||||||
"ultrathink": {
|
|
||||||
"name": "超级思考",
|
|
||||||
"description": "最大思考预算",
|
|
||||||
"prefix": "超级思考"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"buttonTitle": "思考模式:{{mode}}"
|
|
||||||
},
|
|
||||||
"providerSelection": {
|
"providerSelection": {
|
||||||
"title": "选择您的 AI 助手",
|
"title": "选择您的 AI 助手",
|
||||||
"description": "选择一个供应商以开始新对话",
|
"description": "选择一个供应商以开始新对话",
|
||||||
|
|||||||
@@ -110,6 +110,12 @@
|
|||||||
"unsupported": "此浏览器不支持推送通知。",
|
"unsupported": "此浏览器不支持推送通知。",
|
||||||
"denied": "推送通知已被阻止,请在浏览器设置中允许。"
|
"denied": "推送通知已被阻止,请在浏览器设置中允许。"
|
||||||
},
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "声音",
|
||||||
|
"description": "聊天运行完成时播放短提示音。",
|
||||||
|
"enabled": "已启用",
|
||||||
|
"test": "测试声音"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "事件类型",
|
"title": "事件类型",
|
||||||
"actionRequired": "需要处理",
|
"actionRequired": "需要处理",
|
||||||
|
|||||||
37
src/i18n/locales/zh-TW/auth.json
Normal file
37
src/i18n/locales/zh-TW/auth.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"login": {
|
||||||
|
"title": "歡迎回來",
|
||||||
|
"description": "登入您的 CloudCLI 帳戶",
|
||||||
|
"username": "使用者名稱",
|
||||||
|
"password": "密碼",
|
||||||
|
"submit": "登入",
|
||||||
|
"loading": "登入中...",
|
||||||
|
"errors": {
|
||||||
|
"invalidCredentials": "使用者名稱或密碼無效",
|
||||||
|
"requiredFields": "請填寫所有欄位",
|
||||||
|
"networkError": "網路錯誤,請重試。"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"username": "輸入您的使用者名稱",
|
||||||
|
"password": "輸入您的密碼"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "建立帳戶",
|
||||||
|
"username": "使用者名稱",
|
||||||
|
"password": "密碼",
|
||||||
|
"confirmPassword": "確認密碼",
|
||||||
|
"submit": "建立帳戶",
|
||||||
|
"loading": "建立帳戶中...",
|
||||||
|
"errors": {
|
||||||
|
"passwordMismatch": "密碼不一致",
|
||||||
|
"usernameTaken": "使用者名稱已被使用",
|
||||||
|
"weakPassword": "密碼強度太弱"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"logout": {
|
||||||
|
"title": "登出",
|
||||||
|
"confirm": "確定要登出嗎?",
|
||||||
|
"button": "登出"
|
||||||
|
}
|
||||||
|
}
|
||||||
257
src/i18n/locales/zh-TW/chat.json
Normal file
257
src/i18n/locales/zh-TW/chat.json
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
{
|
||||||
|
"codeBlock": {
|
||||||
|
"copy": "複製",
|
||||||
|
"copied": "已複製",
|
||||||
|
"copyCode": "複製程式碼"
|
||||||
|
},
|
||||||
|
"copyMessage": {
|
||||||
|
"copy": "複製訊息",
|
||||||
|
"copied": "訊息已複製",
|
||||||
|
"selectFormat": "選擇複製格式",
|
||||||
|
"copyAsMarkdown": "複製為 Markdown",
|
||||||
|
"copyAsText": "複製為純文字"
|
||||||
|
},
|
||||||
|
"messageTypes": {
|
||||||
|
"user": "U",
|
||||||
|
"error": "錯誤",
|
||||||
|
"tool": "工具",
|
||||||
|
"claude": "Claude",
|
||||||
|
"cursor": "Cursor",
|
||||||
|
"codex": "Codex",
|
||||||
|
"gemini": "Gemini"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"settings": "工具設定",
|
||||||
|
"error": "工具錯誤",
|
||||||
|
"result": "工具結果",
|
||||||
|
"viewParams": "查看輸入參數",
|
||||||
|
"viewRawParams": "查看原始參數",
|
||||||
|
"viewDiff": "查看編輯差異",
|
||||||
|
"creatingFile": "建立新檔案:",
|
||||||
|
"updatingTodo": "更新待辦事項",
|
||||||
|
"read": "讀取",
|
||||||
|
"readFile": "讀取檔案",
|
||||||
|
"updateTodo": "更新待辦清單",
|
||||||
|
"readTodo": "讀取待辦清單",
|
||||||
|
"searchResults": "結果"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"found": "找到 {{count}} 個{{type}}",
|
||||||
|
"file": "檔案",
|
||||||
|
"files": "檔案",
|
||||||
|
"pattern": "模式:",
|
||||||
|
"in": "在:"
|
||||||
|
},
|
||||||
|
"fileOperations": {
|
||||||
|
"updated": "檔案更新成功",
|
||||||
|
"created": "檔案建立成功",
|
||||||
|
"written": "檔案寫入成功",
|
||||||
|
"diff": "差異",
|
||||||
|
"newFile": "新檔案",
|
||||||
|
"viewContent": "查看檔案內容",
|
||||||
|
"viewFullOutput": "查看完整輸出({{count}} 個字元)",
|
||||||
|
"contentDisplayed": "檔案內容顯示在上方的差異檢視中"
|
||||||
|
},
|
||||||
|
"interactive": {
|
||||||
|
"title": "互動式提示",
|
||||||
|
"waiting": "等待您在 CLI 中回應",
|
||||||
|
"instruction": "請在 Claude 執行的終端機中選擇一個選項。",
|
||||||
|
"selectedOption": "✓ Claude 選擇了選項 {{number}}",
|
||||||
|
"instructionDetail": "在 CLI 中,您可以使用方向鍵或輸入數字來互動式地選擇此選項。"
|
||||||
|
},
|
||||||
|
"thinking": {
|
||||||
|
"title": "思考中...",
|
||||||
|
"emoji": "💭 思考中..."
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
"response": "JSON 回應"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"grant": "授予 {{tool}} 權限",
|
||||||
|
"added": "權限已新增",
|
||||||
|
"addTo": "將 {{entry}} 加入允許的工具。",
|
||||||
|
"retry": "權限已儲存。重試請求以使用該工具。",
|
||||||
|
"error": "無法更新權限。請重試。",
|
||||||
|
"openSettings": "開啟設定"
|
||||||
|
},
|
||||||
|
"todo": {
|
||||||
|
"updated": "待辦清單已成功更新",
|
||||||
|
"current": "目前待辦清單"
|
||||||
|
},
|
||||||
|
"plan": {
|
||||||
|
"viewPlan": "📋 查看實作計畫",
|
||||||
|
"title": "實作計畫"
|
||||||
|
},
|
||||||
|
"usageLimit": {
|
||||||
|
"resetAt": "Claude 使用限制已達到。您的限制將在 **{{time}} {{timezone}}** - {{date}} 重置"
|
||||||
|
},
|
||||||
|
"codex": {
|
||||||
|
"permissionMode": "權限模式",
|
||||||
|
"modes": {
|
||||||
|
"default": "預設模式",
|
||||||
|
"auto": "自動模式",
|
||||||
|
"acceptEdits": "編輯模式",
|
||||||
|
"bypassPermissions": "無限制模式",
|
||||||
|
"plan": "計畫模式"
|
||||||
|
},
|
||||||
|
"descriptions": {
|
||||||
|
"default": "只有受信任的指令(ls、cat、grep、git status 等)自動執行。其他指令將被略過。可以寫入工作區。",
|
||||||
|
"auto": "由模型分類器針對每次工具呼叫決定核准或拒絕。免手動操作,但比 Bypass 安全——仍可能發生拒絕。",
|
||||||
|
"acceptEdits": "工作區內的所有指令自動執行。完全自動模式,具有沙箱執行功能。",
|
||||||
|
"bypassPermissions": "完全的系統存取,無限制。所有指令自動執行,具有完整的磁碟和網路存取權限。請謹慎使用。",
|
||||||
|
"plan": "計畫模式 - 不執行任何指令"
|
||||||
|
},
|
||||||
|
"technicalDetails": "技術細節"
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"placeholder": "輸入 / 叫用指令,@ 選取檔案,或向 {{provider}} 提問...",
|
||||||
|
"placeholderDefault": "輸入您的訊息...",
|
||||||
|
"disabled": "輸入已停用",
|
||||||
|
"attachFiles": "附加檔案",
|
||||||
|
"attachImages": "附加圖片",
|
||||||
|
"send": "傳送",
|
||||||
|
"stop": "停止",
|
||||||
|
"hintText": {
|
||||||
|
"ctrlEnter": "Ctrl+Enter 傳送 • Shift+Enter 換行 • Tab 切換模式 • / 斜線指令",
|
||||||
|
"enter": "Enter 傳送 • Shift+Enter 換行 • Tab 切換模式 • / 斜線指令"
|
||||||
|
},
|
||||||
|
"clickToChangeMode": "點擊變更權限模式(或在輸入框中按 Tab)",
|
||||||
|
"showAllCommands": "顯示所有指令",
|
||||||
|
"clearInput": "清空輸入",
|
||||||
|
"scrollToBottom": "捲動到底部"
|
||||||
|
},
|
||||||
|
"thinkingMode": {
|
||||||
|
"selector": {
|
||||||
|
"title": "思考模式",
|
||||||
|
"description": "延伸思考讓 Claude 有更多時間來評估替代方案",
|
||||||
|
"active": "啟用",
|
||||||
|
"tip": "更高的思考模式需要更多時間,但提供更徹底的分析"
|
||||||
|
},
|
||||||
|
"modes": {
|
||||||
|
"none": {
|
||||||
|
"name": "標準",
|
||||||
|
"description": "一般 Claude 回應",
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"think": {
|
||||||
|
"name": "思考",
|
||||||
|
"description": "基本延伸思考",
|
||||||
|
"prefix": "思考"
|
||||||
|
},
|
||||||
|
"thinkHard": {
|
||||||
|
"name": "深入思考",
|
||||||
|
"description": "更徹底的評估",
|
||||||
|
"prefix": "深入思考"
|
||||||
|
},
|
||||||
|
"thinkHarder": {
|
||||||
|
"name": "更深入思考",
|
||||||
|
"description": "考慮替代方案的深度分析",
|
||||||
|
"prefix": "更深入思考"
|
||||||
|
},
|
||||||
|
"ultrathink": {
|
||||||
|
"name": "超級思考",
|
||||||
|
"description": "最大思考預算",
|
||||||
|
"prefix": "超級思考"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buttonTitle": "思考模式:{{mode}}"
|
||||||
|
},
|
||||||
|
"providerSelection": {
|
||||||
|
"title": "選擇您的 AI 助手",
|
||||||
|
"description": "選擇一個提供者以開始新對話",
|
||||||
|
"selectModel": "選擇模型",
|
||||||
|
"providerInfo": {
|
||||||
|
"anthropic": "由 Anthropic 提供",
|
||||||
|
"openai": "由 OpenAI 提供",
|
||||||
|
"cursorEditor": "AI 程式碼編輯器",
|
||||||
|
"google": "由 Google 提供"
|
||||||
|
},
|
||||||
|
"readyPrompt": {
|
||||||
|
"claude": "準備好使用 {{model}} 的 Claude。請在下方開始輸入您的訊息。",
|
||||||
|
"cursor": "準備好使用 {{model}} 的 Cursor。請在下方開始輸入您的訊息。",
|
||||||
|
"codex": "準備好使用 {{model}} 的 Codex。請在下方開始輸入您的訊息。",
|
||||||
|
"gemini": "準備好使用 {{model}} 的 Gemini。請在下方開始輸入您的訊息。",
|
||||||
|
"default": "請在上方選擇一個提供者以開始"
|
||||||
|
},
|
||||||
|
"pressToSearch": "按 <kbd>{{shortcut}}</kbd> 搜尋工作階段、檔案和提交"
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"continue": {
|
||||||
|
"title": "繼續您的對話",
|
||||||
|
"description": "詢問有關程式碼的問題、要求修改或取得開發任務的協助"
|
||||||
|
},
|
||||||
|
"loading": {
|
||||||
|
"olderMessages": "正在載入較早的訊息...",
|
||||||
|
"sessionMessages": "正在載入工作階段訊息..."
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"showingOf": "顯示 {{shown}} / {{total}} 則訊息",
|
||||||
|
"scrollToLoad": "向上捲動以載入更多",
|
||||||
|
"showingLast": "顯示最近 {{count}} 則訊息(共 {{total}} 則)",
|
||||||
|
"loadEarlier": "載入較早的訊息",
|
||||||
|
"loadAll": "載入全部訊息",
|
||||||
|
"loadingAll": "正在載入全部訊息...",
|
||||||
|
"allLoaded": "全部訊息已載入",
|
||||||
|
"perfWarning": "已載入全部訊息 - 捲動可能變慢。點擊「捲動到底部」恢復效能。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"shell": {
|
||||||
|
"selectProject": {
|
||||||
|
"title": "選擇專案",
|
||||||
|
"description": "選擇一個專案以在該目錄中開啟互動式 Shell"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"newSession": "新工作階段",
|
||||||
|
"initializing": "初始化中...",
|
||||||
|
"restarting": "重新啟動中..."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"disconnect": "中斷連線",
|
||||||
|
"disconnectTitle": "中斷 Shell 連線",
|
||||||
|
"restart": "重新啟動",
|
||||||
|
"restartTitle": "重新啟動 Shell(請先中斷連線)",
|
||||||
|
"connect": "在 Shell 中繼續",
|
||||||
|
"connectTitle": "連線到 Shell"
|
||||||
|
},
|
||||||
|
"loading": "正在載入終端機...",
|
||||||
|
"connecting": "正在連線到 Shell...",
|
||||||
|
"startSession": "啟動新的 Claude 工作階段",
|
||||||
|
"resumeSession": "恢復工作階段:{{displayName}}...",
|
||||||
|
"runCommand": "在 {{projectName}} 中執行 {{command}}",
|
||||||
|
"startCli": "在 {{projectName}} 中啟動 Claude CLI",
|
||||||
|
"defaultCommand": "指令"
|
||||||
|
},
|
||||||
|
"claudeStatus": {
|
||||||
|
"actions": {
|
||||||
|
"thinking": "思考中",
|
||||||
|
"processing": "處理中",
|
||||||
|
"analyzing": "分析中",
|
||||||
|
"working": "執行中",
|
||||||
|
"computing": "運算中",
|
||||||
|
"reasoning": "推理中"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"live": "進行中",
|
||||||
|
"paused": "已暫停"
|
||||||
|
},
|
||||||
|
"elapsed": {
|
||||||
|
"seconds": "{{count}} 秒",
|
||||||
|
"minutesSeconds": "{{minutes}} 分 {{seconds}} 秒",
|
||||||
|
"label": "已過 {{time}}",
|
||||||
|
"startingNow": "正要開始"
|
||||||
|
},
|
||||||
|
"controls": {
|
||||||
|
"stopGeneration": "停止生成",
|
||||||
|
"pressEscToStop": "隨時按 Esc 即可停止"
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"assistant": "助理"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"projectSelection": {
|
||||||
|
"startChatWithProvider": "選擇一個專案以開始與 {{provider}} 聊天"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"nextTaskPrompt": "開始下一個任務"
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/i18n/locales/zh-TW/codeEditor.json
Normal file
34
src/i18n/locales/zh-TW/codeEditor.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"toolbar": {
|
||||||
|
"changes": "個變更",
|
||||||
|
"previousChange": "上一個變更",
|
||||||
|
"nextChange": "下一個變更",
|
||||||
|
"hideDiff": "隱藏差異醒目提示",
|
||||||
|
"showDiff": "顯示差異醒目提示",
|
||||||
|
"settings": "編輯器設定",
|
||||||
|
"collapse": "收合編輯器",
|
||||||
|
"expand": "展開編輯器到全寬"
|
||||||
|
},
|
||||||
|
"loading": "正在載入 {{fileName}}...",
|
||||||
|
"header": {
|
||||||
|
"showingChanges": "顯示變更"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"download": "下載檔案",
|
||||||
|
"save": "儲存",
|
||||||
|
"saving": "儲存中...",
|
||||||
|
"saved": "已儲存!",
|
||||||
|
"exitFullscreen": "離開全螢幕",
|
||||||
|
"fullscreen": "全螢幕",
|
||||||
|
"close": "關閉"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"lines": "行數:",
|
||||||
|
"characters": "字元數:",
|
||||||
|
"shortcuts": "按 Ctrl+S 儲存 • Esc 關閉"
|
||||||
|
},
|
||||||
|
"binaryFile": {
|
||||||
|
"title": "二進位檔案",
|
||||||
|
"message": "檔案「{{fileName}}」無法在文字編輯器中顯示,因為它是二進位檔案。"
|
||||||
|
}
|
||||||
|
}
|
||||||
267
src/i18n/locales/zh-TW/common.json
Normal file
267
src/i18n/locales/zh-TW/common.json
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
{
|
||||||
|
"buttons": {
|
||||||
|
"save": "儲存",
|
||||||
|
"cancel": "取消",
|
||||||
|
"delete": "刪除",
|
||||||
|
"create": "建立",
|
||||||
|
"edit": "編輯",
|
||||||
|
"close": "關閉",
|
||||||
|
"confirm": "確認",
|
||||||
|
"submit": "送出",
|
||||||
|
"retry": "重試",
|
||||||
|
"refresh": "重新整理",
|
||||||
|
"search": "搜尋",
|
||||||
|
"clear": "清除",
|
||||||
|
"copy": "複製",
|
||||||
|
"download": "下載",
|
||||||
|
"upload": "上傳",
|
||||||
|
"browse": "瀏覽"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"chat": "聊天",
|
||||||
|
"shell": "終端機",
|
||||||
|
"files": "檔案",
|
||||||
|
"git": "版本控制",
|
||||||
|
"tasks": "任務"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"loading": "載入中...",
|
||||||
|
"success": "成功",
|
||||||
|
"error": "錯誤",
|
||||||
|
"failed": "失敗",
|
||||||
|
"pending": "待處理",
|
||||||
|
"completed": "已完成",
|
||||||
|
"inProgress": "進行中"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"savedSuccessfully": "儲存成功",
|
||||||
|
"deletedSuccessfully": "刪除成功",
|
||||||
|
"updatedSuccessfully": "更新成功",
|
||||||
|
"operationFailed": "操作失敗",
|
||||||
|
"networkError": "網路錯誤,請檢查您的連線。",
|
||||||
|
"unauthorized": "未授權,請登入。",
|
||||||
|
"notFound": "找不到",
|
||||||
|
"invalidInput": "輸入無效",
|
||||||
|
"requiredField": "此欄位為必填",
|
||||||
|
"unknownError": "發生未知錯誤"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"settings": "設定",
|
||||||
|
"home": "首頁",
|
||||||
|
"back": "返回",
|
||||||
|
"next": "下一步",
|
||||||
|
"previous": "上一步",
|
||||||
|
"logout": "登出"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"language": "語言",
|
||||||
|
"theme": "佈景主題",
|
||||||
|
"darkMode": "深色模式",
|
||||||
|
"lightMode": "淺色模式",
|
||||||
|
"name": "名稱",
|
||||||
|
"description": "描述",
|
||||||
|
"enabled": "已啟用",
|
||||||
|
"disabled": "已停用",
|
||||||
|
"optional": "選填",
|
||||||
|
"version": "版本",
|
||||||
|
"select": "選取",
|
||||||
|
"selectAll": "全選",
|
||||||
|
"deselectAll": "取消全選"
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"justNow": "剛剛",
|
||||||
|
"minutesAgo": "{{count}} 分鐘前",
|
||||||
|
"hoursAgo": "{{count}} 小時前",
|
||||||
|
"daysAgo": "{{count}} 天前",
|
||||||
|
"yesterday": "昨天"
|
||||||
|
},
|
||||||
|
"fileOperations": {
|
||||||
|
"newFile": "新增檔案",
|
||||||
|
"newFolder": "新增資料夾",
|
||||||
|
"rename": "重新命名",
|
||||||
|
"move": "移動",
|
||||||
|
"copyPath": "複製路徑",
|
||||||
|
"openInEditor": "在編輯器中開啟"
|
||||||
|
},
|
||||||
|
"mainContent": {
|
||||||
|
"loading": "正在載入 CloudCLI",
|
||||||
|
"settingUpWorkspace": "正在設定您的工作區...",
|
||||||
|
"chooseProject": "選擇您的專案",
|
||||||
|
"selectProjectDescription": "從側邊欄選擇一個專案以開始使用 Claude 進行程式開發。每個專案包含您的聊天紀錄和檔案歷史。",
|
||||||
|
"tip": "提示",
|
||||||
|
"createProjectMobile": "點擊上方的選單按鈕以存取專案",
|
||||||
|
"createProjectDesktop": "點擊側邊欄中的資料夾圖示以建立新專案",
|
||||||
|
"newSession": "新工作階段",
|
||||||
|
"untitledSession": "未命名工作階段",
|
||||||
|
"projectFiles": "專案檔案"
|
||||||
|
},
|
||||||
|
"fileTree": {
|
||||||
|
"loading": "正在載入檔案...",
|
||||||
|
"files": "檔案",
|
||||||
|
"simpleView": "簡易檢視",
|
||||||
|
"compactView": "精簡檢視",
|
||||||
|
"detailedView": "詳細檢視",
|
||||||
|
"searchPlaceholder": "搜尋檔案和資料夾...",
|
||||||
|
"clearSearch": "清除搜尋",
|
||||||
|
"name": "名稱",
|
||||||
|
"size": "大小",
|
||||||
|
"modified": "修改時間",
|
||||||
|
"permissions": "權限",
|
||||||
|
"noFilesFound": "找不到檔案",
|
||||||
|
"checkProjectPath": "請檢查專案路徑是否可存取",
|
||||||
|
"noMatchesFound": "找不到符合項目",
|
||||||
|
"tryDifferentSearch": "嘗試不同的搜尋詞或清除搜尋",
|
||||||
|
"justNow": "剛剛",
|
||||||
|
"minAgo": "{{count}} 分鐘前",
|
||||||
|
"hoursAgo": "{{count}} 小時前",
|
||||||
|
"daysAgo": "{{count}} 天前",
|
||||||
|
"newFile": "新增檔案 (Cmd+N)",
|
||||||
|
"newFolder": "新增資料夾 (Cmd+Shift+N)",
|
||||||
|
"refresh": "重新整理",
|
||||||
|
"collapseAll": "全部收合",
|
||||||
|
"context": {
|
||||||
|
"rename": "重新命名",
|
||||||
|
"delete": "刪除",
|
||||||
|
"copyPath": "複製路徑",
|
||||||
|
"download": "下載",
|
||||||
|
"newFile": "新增檔案",
|
||||||
|
"newFolder": "新增資料夾",
|
||||||
|
"refresh": "重新整理",
|
||||||
|
"menuLabel": "檔案右鍵選單",
|
||||||
|
"loading": "載入中..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"projectWizard": {
|
||||||
|
"title": "建立新專案",
|
||||||
|
"steps": {
|
||||||
|
"type": "類型",
|
||||||
|
"configure": "設定",
|
||||||
|
"confirm": "確認"
|
||||||
|
},
|
||||||
|
"step1": {
|
||||||
|
"question": "您已經有工作區,還是想建立一個新的工作區?",
|
||||||
|
"existing": {
|
||||||
|
"title": "現有工作區",
|
||||||
|
"description": "我的伺服器上已經有工作區,只需要將其加入專案列表"
|
||||||
|
},
|
||||||
|
"new": {
|
||||||
|
"title": "新建工作區",
|
||||||
|
"description": "建立新工作區,可選擇從 GitHub 儲存庫複製"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"step2": {
|
||||||
|
"existingPath": "工作區路徑",
|
||||||
|
"newPath": "工作區路徑",
|
||||||
|
"existingPlaceholder": "/path/to/existing/workspace",
|
||||||
|
"newPlaceholder": "/path/to/new/workspace",
|
||||||
|
"existingHelp": "您現有工作區目錄的完整路徑",
|
||||||
|
"newHelp": "工作區目錄的完整路徑",
|
||||||
|
"githubUrl": "GitHub URL(選填)",
|
||||||
|
"githubPlaceholder": "https://github.com/username/repository",
|
||||||
|
"githubHelp": "選填:提供 GitHub URL 以複製儲存庫",
|
||||||
|
"githubAuth": "GitHub 身分驗證(選填)",
|
||||||
|
"githubAuthHelp": "僅私有儲存庫需要。公開儲存庫無需身分驗證即可複製。",
|
||||||
|
"loadingTokens": "正在載入已儲存的權杖...",
|
||||||
|
"storedToken": "已儲存的權杖",
|
||||||
|
"newToken": "新權杖",
|
||||||
|
"nonePublic": "無(公開)",
|
||||||
|
"selectToken": "選取權杖",
|
||||||
|
"selectTokenPlaceholder": "-- 選取權杖 --",
|
||||||
|
"tokenPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||||
|
"tokenHelp": "此權杖僅用於此操作",
|
||||||
|
"publicRepoInfo": "公開儲存庫不需要身分驗證。如果複製公開儲存庫,可以略過提供權杖。",
|
||||||
|
"noTokensHelp": "沒有可用的已儲存權杖。您可以在 設定 → API 金鑰 中新增權杖以便重複使用。",
|
||||||
|
"optionalTokenPublic": "GitHub 權杖(公開儲存庫可選)",
|
||||||
|
"tokenPublicPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx(公開儲存庫可留空)"
|
||||||
|
},
|
||||||
|
"step3": {
|
||||||
|
"reviewConfig": "檢閱您的設定",
|
||||||
|
"existingWorkspace": "現有工作區",
|
||||||
|
"newWorkspace": "新建工作區",
|
||||||
|
"path": "路徑:",
|
||||||
|
"cloneFrom": "複製自:",
|
||||||
|
"authentication": "身分驗證:",
|
||||||
|
"usingStoredToken": "使用已儲存的權杖:",
|
||||||
|
"usingProvidedToken": "使用提供的權杖",
|
||||||
|
"noAuthentication": "無身分驗證",
|
||||||
|
"sshKey": "SSH 金鑰",
|
||||||
|
"existingInfo": "工作區將加入您的專案列表,並可用於 Claude/Cursor 工作階段。",
|
||||||
|
"newWithClone": "儲存庫將從此資料夾複製。",
|
||||||
|
"newEmpty": "工作區將加入您的專案列表,並可用於 Claude/Cursor 工作階段。",
|
||||||
|
"cloningRepository": "正在複製儲存庫..."
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"cancel": "取消",
|
||||||
|
"back": "返回",
|
||||||
|
"next": "下一步",
|
||||||
|
"createProject": "建立專案",
|
||||||
|
"creating": "建立中...",
|
||||||
|
"cloning": "正在複製..."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"selectType": "請選擇您已有現有工作區還是想建立新工作區",
|
||||||
|
"providePath": "請提供工作區路徑",
|
||||||
|
"failedToCreate": "建立工作區失敗",
|
||||||
|
"failedToCreateFolder": "建立資料夾失敗"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"genericTool": "工具",
|
||||||
|
"codes": {
|
||||||
|
"generic": {
|
||||||
|
"info": {
|
||||||
|
"title": "通知"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"required": {
|
||||||
|
"title": "需要處理",
|
||||||
|
"body": "{{toolName}} 正在等待你的決定。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"run": {
|
||||||
|
"stopped": {
|
||||||
|
"title": "執行已停止",
|
||||||
|
"body": "原因:{{reason}}"
|
||||||
|
},
|
||||||
|
"failed": {
|
||||||
|
"title": "執行失敗"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"notification": {
|
||||||
|
"title": "Agent 通知"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"versionUpdate": {
|
||||||
|
"title": "有可用更新",
|
||||||
|
"newVersionReady": "新版本已準備就緒",
|
||||||
|
"currentVersion": "目前版本",
|
||||||
|
"latestVersion": "最新版本",
|
||||||
|
"whatsNew": "新功能:",
|
||||||
|
"viewFullRelease": "查看完整發行說明",
|
||||||
|
"updateProgress": "更新進度:",
|
||||||
|
"manualUpgrade": "手動升級:",
|
||||||
|
"npmUpgradeCommand": "npm install -g @cloudcli-ai/cloudcli@latest",
|
||||||
|
"manualUpgradeHint": "或點擊「立即更新」以自動執行更新。",
|
||||||
|
"updateCompleted": "更新成功完成!",
|
||||||
|
"restartServer": "請重新啟動伺服器以套用變更。",
|
||||||
|
"updateFailed": "更新失敗",
|
||||||
|
"buttons": {
|
||||||
|
"close": "關閉",
|
||||||
|
"later": "稍後",
|
||||||
|
"copyCommand": "複製指令",
|
||||||
|
"updateNow": "立即更新",
|
||||||
|
"updating": "更新中..."
|
||||||
|
},
|
||||||
|
"ariaLabels": {
|
||||||
|
"closeModal": "關閉版本升級對話框",
|
||||||
|
"showSidebar": "顯示側邊欄",
|
||||||
|
"settings": "設定",
|
||||||
|
"updateAvailable": "有可用更新",
|
||||||
|
"closeSidebar": "關閉側邊欄"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
490
src/i18n/locales/zh-TW/settings.json
Normal file
490
src/i18n/locales/zh-TW/settings.json
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
{
|
||||||
|
"title": "設定",
|
||||||
|
"tabs": {
|
||||||
|
"account": "帳戶",
|
||||||
|
"permissions": "權限",
|
||||||
|
"mcpServers": "MCP 伺服器",
|
||||||
|
"appearance": "外觀"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"title": "帳戶",
|
||||||
|
"language": "語言",
|
||||||
|
"languageLabel": "顯示語言",
|
||||||
|
"languageDescription": "選擇您偏好的介面語言",
|
||||||
|
"username": "使用者名稱",
|
||||||
|
"email": "電子郵件",
|
||||||
|
"profile": "個人檔案",
|
||||||
|
"changePassword": "變更密碼"
|
||||||
|
},
|
||||||
|
"mcp": {
|
||||||
|
"title": "MCP 伺服器",
|
||||||
|
"addServer": "新增伺服器",
|
||||||
|
"editServer": "編輯伺服器",
|
||||||
|
"deleteServer": "刪除伺服器",
|
||||||
|
"serverName": "伺服器名稱",
|
||||||
|
"serverType": "伺服器類型",
|
||||||
|
"config": "設定",
|
||||||
|
"testConnection": "測試連線",
|
||||||
|
"status": "狀態",
|
||||||
|
"connected": "已連線",
|
||||||
|
"disconnected": "未連線",
|
||||||
|
"scope": {
|
||||||
|
"label": "範圍",
|
||||||
|
"user": "使用者",
|
||||||
|
"project": "專案"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"title": "外觀",
|
||||||
|
"theme": "佈景主題",
|
||||||
|
"codeEditor": "程式碼編輯器",
|
||||||
|
"editorTheme": "編輯器佈景主題",
|
||||||
|
"wordWrap": "自動換行",
|
||||||
|
"showMinimap": "顯示縮圖",
|
||||||
|
"lineNumbers": "行號",
|
||||||
|
"fontSize": "字型大小"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"saveChanges": "儲存變更",
|
||||||
|
"resetToDefaults": "重設為預設值",
|
||||||
|
"cancelChanges": "取消變更"
|
||||||
|
},
|
||||||
|
"quickSettings": {
|
||||||
|
"title": "快速設定",
|
||||||
|
"sections": {
|
||||||
|
"appearance": "外觀",
|
||||||
|
"toolDisplay": "工具顯示",
|
||||||
|
"viewOptions": "檢視選項",
|
||||||
|
"inputSettings": "輸入設定"
|
||||||
|
},
|
||||||
|
"darkMode": "深色模式",
|
||||||
|
"autoExpandTools": "自動展開工具",
|
||||||
|
"showRawParameters": "顯示原始參數",
|
||||||
|
"showThinking": "顯示思考過程",
|
||||||
|
"autoScrollToBottom": "自動捲動到底部",
|
||||||
|
"sendByCtrlEnter": "使用 Ctrl+Enter 傳送",
|
||||||
|
"sendByCtrlEnterDescription": "啟用後,按 Ctrl+Enter 傳送訊息,而不是僅按 Enter。這對於使用輸入法的使用者可以避免意外傳送。",
|
||||||
|
"dragHandle": {
|
||||||
|
"dragging": "正在拖曳手柄",
|
||||||
|
"closePanel": "關閉設定面板",
|
||||||
|
"openPanel": "開啟設定面板",
|
||||||
|
"draggingStatus": "正在拖曳...",
|
||||||
|
"toggleAndMove": "點擊切換,拖曳移動"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"terminalShortcuts": {
|
||||||
|
"title": "終端機快速鍵",
|
||||||
|
"sectionKeys": "按鍵",
|
||||||
|
"sectionNavigation": "導覽",
|
||||||
|
"escape": "Escape",
|
||||||
|
"tab": "Tab",
|
||||||
|
"shiftTab": "Shift+Tab",
|
||||||
|
"arrowUp": "向上箭頭",
|
||||||
|
"arrowDown": "向下箭頭",
|
||||||
|
"scrollDown": "捲動到底部",
|
||||||
|
"handle": {
|
||||||
|
"closePanel": "關閉快速鍵面板",
|
||||||
|
"openPanel": "開啟快速鍵面板"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mainTabs": {
|
||||||
|
"label": "設定",
|
||||||
|
"agents": "智慧代理",
|
||||||
|
"appearance": "外觀",
|
||||||
|
"git": "Git",
|
||||||
|
"apiTokens": "API 和權杖",
|
||||||
|
"tasks": "任務",
|
||||||
|
"notifications": "通知",
|
||||||
|
"plugins": "外掛",
|
||||||
|
"about": "關於"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "通知",
|
||||||
|
"description": "控制你希望接收的通知事件。",
|
||||||
|
"webPush": {
|
||||||
|
"title": "Web 推播通知",
|
||||||
|
"enable": "啟用推播通知",
|
||||||
|
"disable": "關閉推播通知",
|
||||||
|
"enabled": "推播通知已啟用",
|
||||||
|
"loading": "更新中...",
|
||||||
|
"unsupported": "此瀏覽器不支援推播通知。",
|
||||||
|
"denied": "推播通知已被封鎖,請在瀏覽器設定中允許。"
|
||||||
|
},
|
||||||
|
"sound": {
|
||||||
|
"title": "聲音",
|
||||||
|
"description": "聊天執行完成時播放短提示音。",
|
||||||
|
"enabled": "已啟用",
|
||||||
|
"test": "測試聲音"
|
||||||
|
},
|
||||||
|
"events": {
|
||||||
|
"title": "事件類型",
|
||||||
|
"actionRequired": "需要處理",
|
||||||
|
"stop": "執行已停止",
|
||||||
|
"error": "執行失敗"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appearanceSettings": {
|
||||||
|
"darkMode": {
|
||||||
|
"label": "深色模式",
|
||||||
|
"description": "切換淺色和深色佈景主題"
|
||||||
|
},
|
||||||
|
"projectSorting": {
|
||||||
|
"label": "專案排序",
|
||||||
|
"description": "專案在側邊欄中的排列方式",
|
||||||
|
"alphabetical": "依字母順序",
|
||||||
|
"recentActivity": "最近活動"
|
||||||
|
},
|
||||||
|
"codeEditor": {
|
||||||
|
"title": "程式碼編輯器",
|
||||||
|
"theme": {
|
||||||
|
"label": "編輯器佈景主題",
|
||||||
|
"description": "程式碼編輯器的預設佈景主題"
|
||||||
|
},
|
||||||
|
"wordWrap": {
|
||||||
|
"label": "自動換行",
|
||||||
|
"description": "在編輯器中預設啟用自動換行"
|
||||||
|
},
|
||||||
|
"showMinimap": {
|
||||||
|
"label": "顯示縮圖",
|
||||||
|
"description": "在差異檢視中顯示縮圖以便於導覽"
|
||||||
|
},
|
||||||
|
"lineNumbers": {
|
||||||
|
"label": "顯示行號",
|
||||||
|
"description": "在編輯器中顯示行號"
|
||||||
|
},
|
||||||
|
"fontSize": {
|
||||||
|
"label": "字型大小",
|
||||||
|
"description": "編輯器字型大小(px)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mcpForm": {
|
||||||
|
"title": {
|
||||||
|
"add": "新增 MCP 伺服器",
|
||||||
|
"edit": "編輯 MCP 伺服器"
|
||||||
|
},
|
||||||
|
"importMode": {
|
||||||
|
"form": "表單輸入",
|
||||||
|
"json": "JSON 匯入"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"label": "範圍",
|
||||||
|
"userGlobal": "使用者(全域)",
|
||||||
|
"projectLocal": "專案(本機)",
|
||||||
|
"userDescription": "使用者範圍:在您機器上的所有專案中可用",
|
||||||
|
"projectDescription": "本機範圍:僅在選定專案中可用",
|
||||||
|
"cannotChange": "編輯現有伺服器時無法變更範圍"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"serverName": "伺服器名稱",
|
||||||
|
"transportType": "傳輸類型",
|
||||||
|
"command": "指令",
|
||||||
|
"arguments": "參數(每行一個)",
|
||||||
|
"jsonConfig": "JSON 設定",
|
||||||
|
"url": "URL",
|
||||||
|
"envVars": "環境變數(KEY=值,每行一個)",
|
||||||
|
"headers": "標頭(KEY=值,每行一個)",
|
||||||
|
"selectProject": "選取專案..."
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"serverName": "我的服務"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"missingType": "缺少必填欄位:type",
|
||||||
|
"stdioRequiresCommand": "stdio 類型需要 command 欄位",
|
||||||
|
"httpRequiresUrl": "{{type}} 類型需要 url 欄位",
|
||||||
|
"invalidJson": "無效的 JSON 格式",
|
||||||
|
"jsonHelp": "貼上您的 MCP 伺服器設定(JSON 格式)。範例格式:",
|
||||||
|
"jsonExampleStdio": "• stdio: {\"type\":\"stdio\",\"command\":\"npx\",\"args\":[\"@upstash/context7-mcp\"]}",
|
||||||
|
"jsonExampleHttp": "• http/sse: {\"type\":\"http\",\"url\":\"https://api.example.com/mcp\"}"
|
||||||
|
},
|
||||||
|
"configDetails": "設定詳細資訊(來自 {{configFile}})",
|
||||||
|
"projectPath": "路徑:{{path}}",
|
||||||
|
"actions": {
|
||||||
|
"cancel": "取消",
|
||||||
|
"saving": "儲存中...",
|
||||||
|
"addServer": "新增伺服器",
|
||||||
|
"updateServer": "更新伺服器"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"saveStatus": {
|
||||||
|
"success": "設定儲存成功!",
|
||||||
|
"error": "儲存設定失敗",
|
||||||
|
"saving": "儲存中..."
|
||||||
|
},
|
||||||
|
"footerActions": {
|
||||||
|
"save": "儲存設定",
|
||||||
|
"cancel": "取消"
|
||||||
|
},
|
||||||
|
"git": {
|
||||||
|
"title": "Git 設定",
|
||||||
|
"description": "設定您的 git 提交身分。這些設定將透過 git config --global 全域套用",
|
||||||
|
"name": {
|
||||||
|
"label": "Git 名稱",
|
||||||
|
"help": "您的 git 提交名稱"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"label": "Git 電子郵件",
|
||||||
|
"help": "您的 git 提交電子郵件"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "儲存設定",
|
||||||
|
"saving": "儲存中..."
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"success": "儲存成功"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apiKeys": {
|
||||||
|
"title": "API 金鑰",
|
||||||
|
"description": "產生 API 金鑰以從其他應用程式存取外部 API。",
|
||||||
|
"newKey": {
|
||||||
|
"alertTitle": "⚠️ 儲存您的 API 金鑰",
|
||||||
|
"alertMessage": "這是您唯一一次看到此金鑰。請妥善保存。",
|
||||||
|
"iveSavedIt": "我已儲存"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"placeholder": "API 金鑰名稱(例如:正式伺服器)",
|
||||||
|
"createButton": "建立",
|
||||||
|
"cancelButton": "取消"
|
||||||
|
},
|
||||||
|
"newButton": "新增 API 金鑰",
|
||||||
|
"empty": "尚未建立 API 金鑰。",
|
||||||
|
"list": {
|
||||||
|
"created": "建立時間:",
|
||||||
|
"lastUsed": "最後使用:"
|
||||||
|
},
|
||||||
|
"confirmDelete": "確定要刪除此 API 金鑰嗎?",
|
||||||
|
"status": {
|
||||||
|
"active": "啟用",
|
||||||
|
"inactive": "未啟用"
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"title": "GitHub 權杖",
|
||||||
|
"description": "新增 GitHub 個人存取權杖以透過外部 API 複製私有儲存庫。",
|
||||||
|
"descriptionAlt": "新增 GitHub 個人存取權杖以複製私有儲存庫。您也可以直接在 API 請求中傳遞權杖而無需儲存。",
|
||||||
|
"addButton": "新增權杖",
|
||||||
|
"form": {
|
||||||
|
"namePlaceholder": "權杖名稱(例如:個人儲存庫)",
|
||||||
|
"tokenPlaceholder": "GitHub 個人存取權杖(ghp_...)",
|
||||||
|
"descriptionPlaceholder": "描述(選填)",
|
||||||
|
"addButton": "新增權杖",
|
||||||
|
"cancelButton": "取消",
|
||||||
|
"howToCreate": "如何建立 GitHub 個人存取權杖 →"
|
||||||
|
},
|
||||||
|
"empty": "尚未新增 GitHub 權杖。",
|
||||||
|
"added": "新增時間:",
|
||||||
|
"confirmDelete": "確定要刪除此 GitHub 權杖嗎?"
|
||||||
|
},
|
||||||
|
"apiDocsLink": "API 文件",
|
||||||
|
"documentation": {
|
||||||
|
"title": "外部 API 文件",
|
||||||
|
"description": "了解如何使用外部 API 從您的應用程式觸發 Claude/Cursor 工作階段。",
|
||||||
|
"viewLink": "查看 API 文件 →"
|
||||||
|
},
|
||||||
|
"loading": "載入中...",
|
||||||
|
"version": {
|
||||||
|
"updateAvailable": "有可用更新:v{{version}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"checking": "正在檢查 TaskMaster 安裝...",
|
||||||
|
"notInstalled": {
|
||||||
|
"title": "未安裝 TaskMaster AI CLI",
|
||||||
|
"description": "需要 TaskMaster CLI 才能使用任務管理功能。安裝它以開始使用:",
|
||||||
|
"installCommand": "npm install -g task-master-ai",
|
||||||
|
"viewOnGitHub": "在 GitHub 上查看",
|
||||||
|
"afterInstallation": "安裝後:",
|
||||||
|
"steps": {
|
||||||
|
"restart": "重新啟動此應用程式",
|
||||||
|
"autoAvailable": "TaskMaster 功能將自動啟用",
|
||||||
|
"initCommand": "在專案目錄中使用 task-master init"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"enableLabel": "啟用 TaskMaster 整合",
|
||||||
|
"enableDescription": "在整個介面中顯示 TaskMaster 任務、橫幅和側邊欄指示器"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"authStatus": {
|
||||||
|
"checking": "檢查中...",
|
||||||
|
"connected": "已連線",
|
||||||
|
"notConnected": "未連線",
|
||||||
|
"disconnected": "已中斷連線",
|
||||||
|
"checkingAuth": "正在檢查驗證狀態...",
|
||||||
|
"loggedInAs": "登入為 {{email}}",
|
||||||
|
"authenticatedUser": "已驗證使用者"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"claude": {
|
||||||
|
"description": "Anthropic Claude AI 助手"
|
||||||
|
},
|
||||||
|
"cursor": {
|
||||||
|
"description": "Cursor AI 驅動的程式碼編輯器"
|
||||||
|
},
|
||||||
|
"codex": {
|
||||||
|
"description": "OpenAI Codex AI 助手"
|
||||||
|
},
|
||||||
|
"gemini": {
|
||||||
|
"description": "Google Gemini AI 助手"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connectionStatus": "連線狀態",
|
||||||
|
"login": {
|
||||||
|
"title": "登入",
|
||||||
|
"reAuthenticate": "重新驗證",
|
||||||
|
"description": "登入您的 {{agent}} 帳戶以啟用 AI 功能",
|
||||||
|
"reAuthDescription": "使用其他帳戶登入或重新整理憑證",
|
||||||
|
"button": "登入",
|
||||||
|
"reLoginButton": "重新登入"
|
||||||
|
},
|
||||||
|
"error": "錯誤:{{error}}"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"title": "權限設定",
|
||||||
|
"skipPermissions": {
|
||||||
|
"label": "略過權限提示(請謹慎使用)",
|
||||||
|
"claudeDescription": "等同於 --dangerously-skip-permissions 旗標",
|
||||||
|
"cursorDescription": "等同於 Cursor CLI 中的 -f 旗標"
|
||||||
|
},
|
||||||
|
"allowedTools": {
|
||||||
|
"title": "允許的工具",
|
||||||
|
"description": "無需權限提示即可自動使用的工具",
|
||||||
|
"placeholder": "例如:\"Bash(git log:*)\" 或 \"Write\"",
|
||||||
|
"quickAdd": "快速新增常用工具:",
|
||||||
|
"empty": "未設定允許的工具"
|
||||||
|
},
|
||||||
|
"blockedTools": {
|
||||||
|
"title": "停用的工具",
|
||||||
|
"description": "無需權限提示即可自動停用的工具",
|
||||||
|
"placeholder": "例如:\"Bash(rm:*)\"",
|
||||||
|
"empty": "未設定停用的工具"
|
||||||
|
},
|
||||||
|
"allowedCommands": {
|
||||||
|
"title": "允許的 Shell 指令",
|
||||||
|
"description": "無需權限提示即可自動執行的 Shell 指令",
|
||||||
|
"placeholder": "例如:\"Shell(ls)\" 或 \"Shell(git status)\"",
|
||||||
|
"quickAdd": "快速新增常用指令:",
|
||||||
|
"empty": "未設定允許的指令"
|
||||||
|
},
|
||||||
|
"blockedCommands": {
|
||||||
|
"title": "封鎖的 Shell 指令",
|
||||||
|
"description": "自動封鎖的 Shell 指令",
|
||||||
|
"placeholder": "例如:\"Shell(rm -rf)\" 或 \"Shell(sudo)\"",
|
||||||
|
"empty": "未設定封鎖的指令"
|
||||||
|
},
|
||||||
|
"toolExamples": {
|
||||||
|
"title": "工具模式範例:",
|
||||||
|
"bashGitLog": "- 允許所有 git log 指令",
|
||||||
|
"bashGitDiff": "- 允許所有 git diff 指令",
|
||||||
|
"write": "- 允許所有 Write 工具使用",
|
||||||
|
"bashRm": "- 封鎖所有 rm 指令(危險)"
|
||||||
|
},
|
||||||
|
"shellExamples": {
|
||||||
|
"title": "Shell 指令範例:",
|
||||||
|
"ls": "- 允許 ls 指令",
|
||||||
|
"gitStatus": "- 允許 git status",
|
||||||
|
"npmInstall": "- 允許 npm install",
|
||||||
|
"rmRf": "- 封鎖遞迴刪除"
|
||||||
|
},
|
||||||
|
"codex": {
|
||||||
|
"permissionMode": "權限模式",
|
||||||
|
"description": "控制 Codex 如何處理檔案修改和指令執行",
|
||||||
|
"modes": {
|
||||||
|
"default": {
|
||||||
|
"title": "預設",
|
||||||
|
"description": "只有受信任的指令(ls、cat、grep、git status 等)會自動執行。其他指令將被略過。可以寫入工作區。"
|
||||||
|
},
|
||||||
|
"acceptEdits": {
|
||||||
|
"title": "接受編輯",
|
||||||
|
"description": "所有指令在工作區內自動執行。具有沙箱執行的全自動模式。"
|
||||||
|
},
|
||||||
|
"bypassPermissions": {
|
||||||
|
"title": "略過權限",
|
||||||
|
"description": "完全系統存取,無任何限制。所有指令自動執行,具有完整的磁碟和網路存取權限。請謹慎使用。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"technicalDetails": "技術詳情",
|
||||||
|
"technicalInfo": {
|
||||||
|
"default": "sandboxMode=workspace-write, approvalPolicy=untrusted。受信任的指令:cat、cd、grep、head、ls、pwd、tail、git status/log/diff/show、find(不帶 -exec)等。",
|
||||||
|
"acceptEdits": "sandboxMode=workspace-write, approvalPolicy=never。所有指令在專案目錄內自動執行。",
|
||||||
|
"bypassPermissions": "sandboxMode=danger-full-access, approvalPolicy=never。完全系統存取權限,僅在可信環境中使用。",
|
||||||
|
"overrideNote": "您可以使用聊天介面中的模式按鈕按工作階段覆蓋此設定。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"add": "新增"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mcpServers": {
|
||||||
|
"title": "MCP 伺服器",
|
||||||
|
"description": {
|
||||||
|
"claude": "Model Context Protocol 伺服器為 Claude 提供額外的工具和資料來源",
|
||||||
|
"cursor": "Model Context Protocol 伺服器為 Cursor 提供額外的工具和資料來源",
|
||||||
|
"codex": "Model Context Protocol 伺服器為 Codex 提供額外的工具和資料來源"
|
||||||
|
},
|
||||||
|
"addButton": "新增 MCP 伺服器",
|
||||||
|
"empty": "未設定 MCP 伺服器",
|
||||||
|
"serverType": "類型",
|
||||||
|
"scope": {
|
||||||
|
"local": "本機",
|
||||||
|
"user": "使用者"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"command": "指令",
|
||||||
|
"url": "URL",
|
||||||
|
"args": "參數",
|
||||||
|
"environment": "環境變數"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"title": "工具",
|
||||||
|
"count": "({{count}}):",
|
||||||
|
"more": "還有 {{count}} 個"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"edit": "編輯伺服器",
|
||||||
|
"delete": "刪除伺服器"
|
||||||
|
},
|
||||||
|
"help": {
|
||||||
|
"title": "關於 Codex MCP",
|
||||||
|
"description": "Codex 支援基於 stdio 的 MCP 伺服器。您可以新增伺服器,透過額外的工具和資源來擴充 Codex 的功能。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pluginSettings": {
|
||||||
|
"title": "外掛",
|
||||||
|
"description": "透過自訂外掛擴充介面。從 git 安裝或直接將資料夾放入 ~/.claude-code-ui/plugins/",
|
||||||
|
"installPlaceholder": "https://github.com/user/my-plugin",
|
||||||
|
"installButton": "安裝",
|
||||||
|
"installing": "安裝中…",
|
||||||
|
"securityWarning": "僅安裝您已審查過原始碼或信任作者的外掛。",
|
||||||
|
"scanningPlugins": "正在掃描外掛…",
|
||||||
|
"noPluginsInstalled": "未安裝外掛",
|
||||||
|
"pullLatest": "從 git 拉取最新內容",
|
||||||
|
"noGitRemote": "無 git 遠端儲存庫 — 無法更新",
|
||||||
|
"uninstallPlugin": "解除安裝外掛",
|
||||||
|
"confirmUninstall": "再次點擊確認",
|
||||||
|
"confirmUninstallMessage": "移除 {{name}}?此操作無法復原。",
|
||||||
|
"cancel": "取消",
|
||||||
|
"remove": "移除",
|
||||||
|
"updateFailed": "更新失敗",
|
||||||
|
"installFailed": "安裝失敗",
|
||||||
|
"uninstallFailed": "解除安裝失敗",
|
||||||
|
"toggleFailed": "切換失敗",
|
||||||
|
"buildYourOwn": "建立您自己的外掛",
|
||||||
|
"starter": "入門範本",
|
||||||
|
"docs": "文件",
|
||||||
|
"starterPlugin": {
|
||||||
|
"name": "專案統計",
|
||||||
|
"badge": "入門",
|
||||||
|
"description": "查看專案的檔案數、程式碼行數、檔案類型分佈以及最近活動。",
|
||||||
|
"install": "安裝"
|
||||||
|
},
|
||||||
|
"morePlugins": "更多",
|
||||||
|
"enable": "啟用",
|
||||||
|
"disable": "停用",
|
||||||
|
"installAriaLabel": "外掛 git 儲存庫 URL",
|
||||||
|
"tab": "分頁",
|
||||||
|
"runningStatus": "執行中"
|
||||||
|
}
|
||||||
|
}
|
||||||
136
src/i18n/locales/zh-TW/sidebar.json
Normal file
136
src/i18n/locales/zh-TW/sidebar.json
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
{
|
||||||
|
"projects": {
|
||||||
|
"title": "專案",
|
||||||
|
"newProject": "新增專案",
|
||||||
|
"deleteProject": "移除專案",
|
||||||
|
"renameProject": "重新命名專案",
|
||||||
|
"noProjects": "找不到專案",
|
||||||
|
"loadingProjects": "載入專案中...",
|
||||||
|
"searchPlaceholder": "搜尋專案...",
|
||||||
|
"projectNamePlaceholder": "專案名稱",
|
||||||
|
"starred": "星號標記",
|
||||||
|
"all": "全部",
|
||||||
|
"untitledSession": "未命名工作階段",
|
||||||
|
"newSession": "新工作階段",
|
||||||
|
"codexSession": "Codex 工作階段",
|
||||||
|
"fetchingProjects": "正在取得您的 Claude 專案和工作階段",
|
||||||
|
"projects": "專案",
|
||||||
|
"noMatchingProjects": "找不到符合的專案",
|
||||||
|
"tryDifferentSearch": "嘗試調整您的搜尋詞",
|
||||||
|
"runClaudeCli": "在專案目錄中執行 Claude CLI 以開始使用"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"title": "CloudCLI",
|
||||||
|
"subtitle": "AI 程式開發助手"
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"title": "工作階段",
|
||||||
|
"newSession": "新增工作階段",
|
||||||
|
"deleteSession": "刪除工作階段",
|
||||||
|
"renameSession": "重新命名工作階段",
|
||||||
|
"noSessions": "暫無工作階段",
|
||||||
|
"loadingSessions": "載入工作階段中...",
|
||||||
|
"unnamed": "未命名",
|
||||||
|
"loading": "載入中...",
|
||||||
|
"showMore": "顯示更多工作階段"
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"viewEnvironments": "查看環境",
|
||||||
|
"hideSidebar": "隱藏側邊欄",
|
||||||
|
"createProject": "建立新專案",
|
||||||
|
"refresh": "重新整理專案和工作階段 (Ctrl+R)",
|
||||||
|
"renameProject": "重新命名專案 (F2)",
|
||||||
|
"deleteProject": "從側邊欄移除專案 (Delete)",
|
||||||
|
"addToFavorites": "加入收藏",
|
||||||
|
"removeFromFavorites": "從收藏移除",
|
||||||
|
"editSessionName": "手動編輯工作階段名稱",
|
||||||
|
"deleteSession": "永久刪除此工作階段",
|
||||||
|
"save": "儲存",
|
||||||
|
"cancel": "取消",
|
||||||
|
"clearSearch": "清除搜尋",
|
||||||
|
"openCommandPalette": "開啟指令面板"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"chat": "聊天",
|
||||||
|
"files": "檔案",
|
||||||
|
"git": "Git",
|
||||||
|
"terminal": "終端機",
|
||||||
|
"tasks": "任務"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"refresh": "重新整理",
|
||||||
|
"settings": "設定",
|
||||||
|
"collapseAll": "全部收合",
|
||||||
|
"expandAll": "全部展開",
|
||||||
|
"cancel": "取消",
|
||||||
|
"save": "儲存",
|
||||||
|
"delete": "刪除",
|
||||||
|
"rename": "重新命名",
|
||||||
|
"joinCommunity": "加入社群",
|
||||||
|
"reportIssue": "回報問題",
|
||||||
|
"starOnGithub": "在 GitHub 上加星"
|
||||||
|
},
|
||||||
|
"branding": {
|
||||||
|
"openSource": "開源"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"active": "使用中",
|
||||||
|
"inactive": "非使用中",
|
||||||
|
"thinking": "思考中...",
|
||||||
|
"error": "錯誤",
|
||||||
|
"aborted": "已中止",
|
||||||
|
"unknown": "未知"
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"justNow": "剛剛",
|
||||||
|
"oneMinuteAgo": "1 分鐘前",
|
||||||
|
"minutesAgo": "{{count}} 分鐘前",
|
||||||
|
"oneHourAgo": "1 小時前",
|
||||||
|
"hoursAgo": "{{count}} 小時前",
|
||||||
|
"oneDayAgo": "1 天前",
|
||||||
|
"daysAgo": "{{count}} 天前"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"deleteConfirm": "確定要刪除嗎?",
|
||||||
|
"renameSuccess": "重新命名成功",
|
||||||
|
"deleteSuccess": "刪除成功",
|
||||||
|
"errorOccurred": "發生錯誤",
|
||||||
|
"deleteSessionConfirm": "確定要刪除此工作階段嗎?此操作無法復原。",
|
||||||
|
"deleteProjectConfirm": "從側邊欄移除此專案?您的專案檔案、記憶和工作階段資料不會被刪除。",
|
||||||
|
"enterProjectPath": "請輸入專案路徑",
|
||||||
|
"deleteSessionFailed": "刪除工作階段失敗,請重試。",
|
||||||
|
"deleteSessionError": "刪除工作階段時出錯,請重試。",
|
||||||
|
"renameSessionFailed": "重新命名工作階段失敗,請重試。",
|
||||||
|
"renameSessionError": "重新命名工作階段時出錯,請重試。",
|
||||||
|
"deleteProjectFailed": "移除專案失敗,請重試。",
|
||||||
|
"deleteProjectError": "移除專案時出錯,請重試。",
|
||||||
|
"createProjectFailed": "建立專案失敗,請重試。",
|
||||||
|
"createProjectError": "建立專案時出錯,請重試。"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"updateAvailable": "有可用更新"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"modeProjects": "專案",
|
||||||
|
"modeConversations": "對話",
|
||||||
|
"conversationsPlaceholder": "搜尋對話內容...",
|
||||||
|
"searching": "搜尋中...",
|
||||||
|
"noResults": "找不到結果",
|
||||||
|
"tryDifferentQuery": "嘗試不同的搜尋詞",
|
||||||
|
"matches_one": "{{count}} 個符合",
|
||||||
|
"matches_other": "{{count}} 個符合",
|
||||||
|
"projectsScanned_one": "{{count}} 個專案已掃描",
|
||||||
|
"projectsScanned_other": "{{count}} 個專案已掃描"
|
||||||
|
},
|
||||||
|
"deleteConfirmation": {
|
||||||
|
"deleteProject": "移除專案",
|
||||||
|
"deleteSession": "刪除工作階段",
|
||||||
|
"confirmDelete": "您想如何處理",
|
||||||
|
"sessionCount_one": "此專案包含 {{count}} 個對話。",
|
||||||
|
"sessionCount_other": "此專案包含 {{count}} 個對話。",
|
||||||
|
"removeFromSidebar": "僅從側邊欄移除",
|
||||||
|
"deleteAllData": "永久刪除所有資料",
|
||||||
|
"allConversationsDeleted": "專案將從側邊欄中移除。您的檔案、記憶和工作階段資料將會保留。",
|
||||||
|
"cannotUndo": "您可以稍後重新新增此專案。"
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src/i18n/locales/zh-TW/tasks.json
Normal file
142
src/i18n/locales/zh-TW/tasks.json
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
{
|
||||||
|
"notConfigured": {
|
||||||
|
"title": "TaskMaster AI 尚未設定",
|
||||||
|
"description": "TaskMaster 協助將複雜的專案分解為可管理的任務,搭配 AI 驅動的輔助功能",
|
||||||
|
"whatIsTitle": "🎯 什麼是 TaskMaster?",
|
||||||
|
"features": {
|
||||||
|
"aiPowered": "AI 驅動的任務管理:將複雜專案分解為可管理的子任務",
|
||||||
|
"prdTemplates": "PRD 範本:從產品需求文件產生任務",
|
||||||
|
"dependencyTracking": "相依性追蹤:了解任務關係和執行順序",
|
||||||
|
"progressVisualization": "進度視覺化:看板和詳細的任務分析",
|
||||||
|
"cliIntegration": "CLI 整合:使用 taskmaster 指令進行進階工作流程"
|
||||||
|
},
|
||||||
|
"initializeButton": "初始化 TaskMaster AI"
|
||||||
|
},
|
||||||
|
"gettingStarted": {
|
||||||
|
"title": "開始使用 TaskMaster",
|
||||||
|
"subtitle": "TaskMaster 已初始化!以下是接下來要做的事:",
|
||||||
|
"steps": {
|
||||||
|
"createPRD": {
|
||||||
|
"title": "建立產品需求文件(PRD)",
|
||||||
|
"description": "討論您的專案構想並建立描述您想建立什麼的 PRD。",
|
||||||
|
"addButton": "新增 PRD",
|
||||||
|
"existingPRDs": "現有的 PRD:"
|
||||||
|
},
|
||||||
|
"generateTasks": {
|
||||||
|
"title": "從 PRD 產生任務",
|
||||||
|
"description": "一旦您有了 PRD,請 AI 助手解析它,TaskMaster 將自動將其分解為可管理的任務,包含實作細節。"
|
||||||
|
},
|
||||||
|
"analyzeTasks": {
|
||||||
|
"title": "分析並展開任務",
|
||||||
|
"description": "請 AI 助手分析任務複雜度,並將其展開為詳細的子任務以便於實作。"
|
||||||
|
},
|
||||||
|
"startBuilding": {
|
||||||
|
"title": "開始建構",
|
||||||
|
"description": "請 AI 助手開始處理任務、更新狀態,並在專案演進時新增任務。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tip": "💡 提示:從 PRD 開始可以充分利用 TaskMaster 的 AI 驅動任務產生功能"
|
||||||
|
},
|
||||||
|
"setupModal": {
|
||||||
|
"title": "TaskMaster 設定",
|
||||||
|
"subtitle": "{{projectName}} 的互動式 CLI",
|
||||||
|
"willStart": "TaskMaster 初始化將自動開始",
|
||||||
|
"completed": "TaskMaster 設定完成!您現在可以關閉此視窗。",
|
||||||
|
"closeButton": "關閉",
|
||||||
|
"closeContinueButton": "關閉並繼續"
|
||||||
|
},
|
||||||
|
"helpGuide": {
|
||||||
|
"title": "開始使用 TaskMaster",
|
||||||
|
"subtitle": "您的高效任務管理指南",
|
||||||
|
"examples": {
|
||||||
|
"parsePRD": "💬 範例:\n「我剛用 Claude Task Master 初始化了一個新專案。我有一個 PRD 在 .taskmaster/docs/prd.txt。你能幫我解析它並設定初始任務嗎?」",
|
||||||
|
"expandTask": "💬 範例:\n「任務 5 看起來很複雜。你能把它分解成子任務嗎?」",
|
||||||
|
"addTask": "💬 範例:\n「請新增一個任務來實作使用 Cloudinary 的使用者個人頭像上傳功能,研究最佳方法。」"
|
||||||
|
},
|
||||||
|
"moreExamples": "查看更多範例和使用模式 →",
|
||||||
|
"proTips": {
|
||||||
|
"title": "💡 專業提示",
|
||||||
|
"search": "使用搜尋列快速找到特定任務",
|
||||||
|
"views": "使用檢視切換在看板、清單和網格檢視之間切換",
|
||||||
|
"filters": "使用篩選器聚焦特定任務狀態或優先順序",
|
||||||
|
"details": "點擊任何任務以查看詳細資訊和管理子任務"
|
||||||
|
},
|
||||||
|
"learnMore": {
|
||||||
|
"title": "📚 了解更多",
|
||||||
|
"description": "TaskMaster AI 是為開發者打造的進階任務管理系統。取得文件、範例並為專案做出貢獻。",
|
||||||
|
"githubButton": "在 GitHub 上查看"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"placeholder": "搜尋任務..."
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"button": "篩選",
|
||||||
|
"status": "狀態",
|
||||||
|
"priority": "優先順序",
|
||||||
|
"sortBy": "排序依據",
|
||||||
|
"allStatuses": "所有狀態",
|
||||||
|
"allPriorities": "所有優先順序",
|
||||||
|
"showing": "顯示 {{filtered}} / {{total}} 個任務",
|
||||||
|
"clearFilters": "清除篩選"
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"id": "ID",
|
||||||
|
"status": "狀態",
|
||||||
|
"priority": "優先順序",
|
||||||
|
"idAsc": "ID(遞增)",
|
||||||
|
"idDesc": "ID(遞減)",
|
||||||
|
"titleAsc": "標題(A-Z)",
|
||||||
|
"titleDesc": "標題(Z-A)",
|
||||||
|
"statusAsc": "狀態(待處理優先)",
|
||||||
|
"statusDesc": "狀態(已完成優先)",
|
||||||
|
"priorityAsc": "優先順序(高優先)",
|
||||||
|
"priorityDesc": "優先順序(低優先)"
|
||||||
|
},
|
||||||
|
"views": {
|
||||||
|
"kanban": "看板檢視",
|
||||||
|
"list": "清單檢視",
|
||||||
|
"grid": "網格檢視"
|
||||||
|
},
|
||||||
|
"kanban": {
|
||||||
|
"pending": "📋 待辦",
|
||||||
|
"inProgress": "🚀 進行中",
|
||||||
|
"done": "✅ 已完成",
|
||||||
|
"blocked": "🚫 已封鎖",
|
||||||
|
"deferred": "⏳ 已延後",
|
||||||
|
"cancelled": "❌ 已取消",
|
||||||
|
"noTasksYet": "尚無任務",
|
||||||
|
"tasksWillAppear": "任務將顯示在這裡",
|
||||||
|
"moveTasksHere": "開始後將任務移到這裡",
|
||||||
|
"completedTasksHere": "已完成的任務顯示在這裡",
|
||||||
|
"statusTasksHere": "此狀態的任務將顯示在這裡"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"help": "TaskMaster 入門指南",
|
||||||
|
"prds": "PRD",
|
||||||
|
"addPRD": "新增 PRD",
|
||||||
|
"addTask": "新增任務",
|
||||||
|
"createNewPRD": "建立新 PRD",
|
||||||
|
"prdsAvailable": "{{count}} 個 PRD 可用"
|
||||||
|
},
|
||||||
|
"prd": {
|
||||||
|
"modified": "修改時間:{{date}}"
|
||||||
|
},
|
||||||
|
"statuses": {
|
||||||
|
"pending": "待處理",
|
||||||
|
"in-progress": "進行中",
|
||||||
|
"done": "已完成",
|
||||||
|
"blocked": "已封鎖",
|
||||||
|
"deferred": "已延後",
|
||||||
|
"cancelled": "已取消"
|
||||||
|
},
|
||||||
|
"priorities": {
|
||||||
|
"high": "高",
|
||||||
|
"medium": "中",
|
||||||
|
"low": "低"
|
||||||
|
},
|
||||||
|
"noMatchingTasks": {
|
||||||
|
"title": "沒有符合篩選條件的任務",
|
||||||
|
"description": "嘗試調整您的搜尋或篩選條件。"
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/utils/notificationSound.ts
Normal file
83
src/utils/notificationSound.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
const NOTIFICATION_SOUND_ENABLED_STORAGE_KEY = 'notificationSoundEnabled';
|
||||||
|
const AudioContextConstructor =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let audioContext: AudioContext | null = null;
|
||||||
|
|
||||||
|
export const isNotificationSoundEnabled = (): boolean => {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return localStorage.getItem(NOTIFICATION_SOUND_ENABLED_STORAGE_KEY) !== 'false';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setNotificationSoundEnabled = (enabled: boolean): void => {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(NOTIFICATION_SOUND_ENABLED_STORAGE_KEY, String(enabled));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAudioContext = (): AudioContext | null => {
|
||||||
|
if (!AudioContextConstructor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!audioContext) {
|
||||||
|
audioContext = new AudioContextConstructor();
|
||||||
|
}
|
||||||
|
|
||||||
|
return audioContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
const playTone = (
|
||||||
|
context: AudioContext,
|
||||||
|
frequency: number,
|
||||||
|
startsAt: number,
|
||||||
|
duration: number,
|
||||||
|
peakVolume: number,
|
||||||
|
): void => {
|
||||||
|
const oscillator = context.createOscillator();
|
||||||
|
const gain = context.createGain();
|
||||||
|
|
||||||
|
oscillator.type = 'sine';
|
||||||
|
oscillator.frequency.setValueAtTime(frequency, startsAt);
|
||||||
|
|
||||||
|
// Shape the volume so the synthesized tone starts and stops cleanly.
|
||||||
|
gain.gain.setValueAtTime(0.0001, startsAt);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(peakVolume, startsAt + 0.015);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, startsAt + duration);
|
||||||
|
|
||||||
|
oscillator.connect(gain);
|
||||||
|
gain.connect(context.destination);
|
||||||
|
oscillator.start(startsAt);
|
||||||
|
oscillator.stop(startsAt + duration + 0.02);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const playChatCompletionSound = async ({ force = false } = {}): Promise<void> => {
|
||||||
|
if (!force && !isNotificationSoundEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = getAudioContext();
|
||||||
|
if (!context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (context.state === 'suspended') {
|
||||||
|
await context.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = context.currentTime;
|
||||||
|
playTone(context, 740, now, 0.12, 0.075);
|
||||||
|
playTone(context, 988, now + 0.11, 0.16, 0.06);
|
||||||
|
} catch (error) {
|
||||||
|
// Browsers may block audio until the page receives a user gesture.
|
||||||
|
console.warn('Unable to play notification sound:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
112
src/utils/pageTitleNotification.ts
Normal file
112
src/utils/pageTitleNotification.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
const COMPLETION_TITLE_INDICATOR = '[Done]';
|
||||||
|
const TITLE_INDICATOR_CLEAR_DELAY_MS = 2000;
|
||||||
|
|
||||||
|
let clearTimer: number | null = null;
|
||||||
|
let returnListenersAttached = false;
|
||||||
|
|
||||||
|
const getIndicatorPrefix = () => `${COMPLETION_TITLE_INDICATOR} `;
|
||||||
|
|
||||||
|
const stripIndicator = (title: string): string => {
|
||||||
|
const prefix = getIndicatorPrefix();
|
||||||
|
return title.startsWith(prefix) ? title.slice(prefix.length) : title;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageIsActive = (): boolean => (
|
||||||
|
document.visibilityState === 'visible' && document.hasFocus()
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeReturnListeners = (): void => {
|
||||||
|
if (!returnListenersAttached || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.removeEventListener('visibilitychange', handleUserReturn);
|
||||||
|
window.removeEventListener('focus', handleUserReturn, true);
|
||||||
|
window.removeEventListener('click', handleUserReturn, true);
|
||||||
|
returnListenersAttached = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearTitleIndicator = (): void => {
|
||||||
|
if (clearTimer !== null) {
|
||||||
|
window.clearTimeout(clearTimer);
|
||||||
|
clearTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeReturnListeners();
|
||||||
|
removePageInactiveListener();
|
||||||
|
|
||||||
|
if (document.title.startsWith(getIndicatorPrefix())) {
|
||||||
|
document.title = stripIndicator(document.title);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePageInactiveListener = (): void => {
|
||||||
|
document.removeEventListener('visibilitychange', handlePageInactive);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleClear = (): void => {
|
||||||
|
if (clearTimer !== null) {
|
||||||
|
window.clearTimeout(clearTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimer = window.setTimeout(() => {
|
||||||
|
clearTitleIndicator();
|
||||||
|
}, TITLE_INDICATOR_CLEAR_DELAY_MS);
|
||||||
|
|
||||||
|
removePageInactiveListener();
|
||||||
|
document.addEventListener('visibilitychange', handlePageInactive, { once: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleUserReturn(): void {
|
||||||
|
if (!pageIsActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background completions keep the marker indefinitely. A tab click normally
|
||||||
|
// surfaces as visibility/focus, while an in-page click is a useful fallback.
|
||||||
|
scheduleClear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageInactive(): void {
|
||||||
|
if (document.visibilityState !== 'hidden') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearTimer !== null) {
|
||||||
|
window.clearTimeout(clearTimer);
|
||||||
|
clearTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!returnListenersAttached) {
|
||||||
|
document.addEventListener('visibilitychange', handleUserReturn);
|
||||||
|
window.addEventListener('focus', handleUserReturn, true);
|
||||||
|
window.addEventListener('click', handleUserReturn, true);
|
||||||
|
returnListenersAttached = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const showCompletionTitleIndicator = (): void => {
|
||||||
|
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseTitle = stripIndicator(document.title || 'CloudCLI UI');
|
||||||
|
document.title = `${getIndicatorPrefix()}${baseTitle}`;
|
||||||
|
|
||||||
|
if (pageIsActive()) {
|
||||||
|
scheduleClear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearTimer !== null) {
|
||||||
|
window.clearTimeout(clearTimer);
|
||||||
|
clearTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!returnListenersAttached) {
|
||||||
|
document.addEventListener('visibilitychange', handleUserReturn);
|
||||||
|
window.addEventListener('focus', handleUserReturn, true);
|
||||||
|
window.addEventListener('click', handleUserReturn, true);
|
||||||
|
returnListenersAttached = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user