sync: update from private repo (d31b280)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
d061ad08d8
commit
3b1645cc91
@ -64,7 +64,11 @@ llm:
|
|||||||
# max_concurrency: 2
|
# max_concurrency: 2
|
||||||
# enabled: true
|
# enabled: true
|
||||||
|
|
||||||
# 例: タイトル生成専用ワーカー (chat ジョブは受け付けない)
|
# 例: タイトル生成用ワーカー (chat ジョブは受け付けない)。
|
||||||
|
# 注意: タスク作成時にタイトル用 LLM はもう呼ばれない (実行中にエージェントが
|
||||||
|
# Mission Brief の goal からタイトルを派生する)。このワーカーは
|
||||||
|
# 「AIでタイトルを再生成」ボタン (POST /api/local/tasks/:id/regenerate-title)
|
||||||
|
# のオンデマンド生成にのみ使われる。未設定なら先頭ワーカーが代替する。
|
||||||
# - id: title-worker
|
# - id: title-worker
|
||||||
# connection_type: direct
|
# connection_type: direct
|
||||||
# endpoint: http://localhost:11434/v1
|
# endpoint: http://localhost:11434/v1
|
||||||
@ -265,6 +269,7 @@ tools:
|
|||||||
# office_pdf_max_size_mb: 10 # ReadPdf 上限
|
# office_pdf_max_size_mb: 10 # ReadPdf 上限
|
||||||
# office_pptx_max_size_mb: 50 # ReadPPTX 上限
|
# office_pptx_max_size_mb: 50 # ReadPPTX 上限
|
||||||
# office_pptx_max_uncompressed_mb: 200 # PPTX ZIP 展開後上限 (zip-bomb 検知)
|
# office_pptx_max_uncompressed_mb: 200 # PPTX ZIP 展開後上限 (zip-bomb 検知)
|
||||||
|
# office_msg_max_size_mb: 25 # ReadMsg 上限 (default 25)
|
||||||
# speech_server_url: http://localhost:8000/v1
|
# speech_server_url: http://localhost:8000/v1
|
||||||
# speech_timeout: 300
|
# speech_timeout: 300
|
||||||
# speech_language: ja
|
# speech_language: ja
|
||||||
@ -307,6 +312,29 @@ tools:
|
|||||||
# total_max_kb: 32
|
# total_max_kb: 32
|
||||||
# over_budget_strategy: skip_remaining # truncate_last / skip_remaining (default) / degrade_to_search
|
# over_budget_strategy: skip_remaining # truncate_last / skip_remaining (default) / degrade_to_search
|
||||||
|
|
||||||
|
# ─── サーバー TLS (オプション) ───────────────────────────────
|
||||||
|
# 【フレッシュインストール】setup.sh が server.tls.enabled: true を自動書き込む。
|
||||||
|
# 【アップグレード】server ブロック未記載の場合は false のまま(既存デプロイを壊さない)。
|
||||||
|
# 【リバースプロキシ構成】このブロックを省略するか enabled: false のままにすること。
|
||||||
|
# プロキシが TLS を終端しているので、ここで有効にすると二重終端になり接続が壊れる。
|
||||||
|
#
|
||||||
|
# server:
|
||||||
|
# tls:
|
||||||
|
# enabled: true # フレッシュインストールのデフォルト; ブロック未記載=アップグレード時 false
|
||||||
|
# cert_file: null # PEM 証明書パス (任意); cert_file と key_file は両方設定するか両方省略
|
||||||
|
# key_file: null
|
||||||
|
# min_version: TLSv1.2
|
||||||
|
# self_signed_dir: ./data/tls
|
||||||
|
# self_signed_hosts: [] # localhost / 127.0.0.1 / ::1 / hostname は常に含まれる
|
||||||
|
# http_redirect: true
|
||||||
|
# http_redirect_port: 9080 # HTTPS ポートと異なる値にすること
|
||||||
|
# redirect_host: null # リダイレクト先のホスト; null = バインドホストを使用
|
||||||
|
# # リバースプロキシ構成: このブロックを省略するか enabled: false のままにすること。
|
||||||
|
# # プロキシが TLS を終端している場合に native TLS を有効にすると二重終端になり壊れる。
|
||||||
|
# # noVNC / SSH コンソールを wss で使う場合は「信頼済み証明書」が必要
|
||||||
|
# # (自己署名の wss はブラウザに click-through がない) —
|
||||||
|
# # cert_file / key_file で実証明書を指定するか、OS の信頼ストアに自己署名 CA を登録すること。
|
||||||
|
|
||||||
# ─── 認証 (オプション) ────────────────────────────────────────
|
# ─── 認証 (オプション) ────────────────────────────────────────
|
||||||
# 未設定なら認証なしで動作 (従来互換)。
|
# 未設定なら認証なしで動作 (従来互換)。
|
||||||
# auth:
|
# auth:
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Office ファイル系ツール(ReadPdf / ReadExcel / ReadDocx / ReadPPTX / PdfToImages / SplitExcelSheets / SplitDocxSections)
|
# Office ファイル系ツール(ReadPdf / ReadExcel / ReadDocx / ReadPPTX / ReadMsg / PdfToImages / SplitExcelSheets / SplitDocxSections)
|
||||||
|
|
||||||
ローカル workspace の Office 文書・PDF を読み込むツール群。
|
ローカル workspace の Office 文書・PDF を読み込むツール群。
|
||||||
|
|
||||||
@ -126,6 +126,38 @@ ReadPPTX({ file_path: "input/slides.pptx" })
|
|||||||
// → 各スライドのテキスト・表・スピーカーノートを返す
|
// → 各スライドのテキスト・表・スピーカーノートを返す
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### ReadMsg
|
||||||
|
|
||||||
|
Outlook の `.msg`(OLE2 / CFBF 複合バイナリ)メールを読む。
|
||||||
|
|
||||||
|
```js
|
||||||
|
ReadMsg({ file_path: "input/inquiry.msg" })
|
||||||
|
// → 件名・差出人・宛先・CC・日時・本文(テキスト)と添付一覧を返す
|
||||||
|
```
|
||||||
|
|
||||||
|
返却テキストの構成:
|
||||||
|
|
||||||
|
```
|
||||||
|
Subject: 見積もりのご依頼
|
||||||
|
From: 山田太郎 <taro@example.com>
|
||||||
|
To: sales@example.com
|
||||||
|
Date: ...
|
||||||
|
|
||||||
|
(本文テキスト)
|
||||||
|
|
||||||
|
Attachments (2):
|
||||||
|
- 見積書.pdf (20480 bytes) -> input/見積書.pdf
|
||||||
|
- data.xlsx (8192 bytes) -> input/data.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
ポイント:
|
||||||
|
|
||||||
|
- 本文は plain text を優先。HTML メールはタグを除去して整形。どちらも取れない場合は `(no text body)`
|
||||||
|
- **添付は `input/` に自動保存される**。保存後は種別ごとのツールで開く(PDF → ReadPdf、Excel → ReadExcel、画像 → ReadImage など)
|
||||||
|
- ファイル名は basename に正規化し、パス区切りや制御文字を除去(ディレクトリトラバーサル防止)。同名衝突時は連番を付与
|
||||||
|
- 添付に埋め込まれた `.msg`(メール in メール)は保存せず一覧に注記のみ。必要なら個別に扱う
|
||||||
|
- 不正な `.msg`(CFBF シグネチャ不一致)は中身を読まずエラーを返す。バイナリがそのまま出力に混ざることはない
|
||||||
|
|
||||||
## 変換・分割系
|
## 変換・分割系
|
||||||
|
|
||||||
### PdfToImages
|
### PdfToImages
|
||||||
|
|||||||
64
package-lock.json
generated
64
package-lock.json
generated
@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@kenjiuno/msgreader": "^1.28.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@novnc/novnc": "^1.6.0",
|
"@novnc/novnc": "^1.6.0",
|
||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
@ -33,6 +34,7 @@
|
|||||||
"pptxgenjs": "^4.0.1",
|
"pptxgenjs": "^4.0.1",
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
|
"selfsigned": "^2.4.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"ssh2": "^1.17.0",
|
"ssh2": "^1.17.0",
|
||||||
"undici": "^7.25.0",
|
"undici": "^7.25.0",
|
||||||
@ -675,6 +677,37 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@kenjiuno/decompressrtf": {
|
||||||
|
"version": "0.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kenjiuno/decompressrtf/-/decompressrtf-0.1.4.tgz",
|
||||||
|
"integrity": "sha512-v9c/iFz17jRWyd2cRnrvJg4VOg/4I/VCk+bG8JnoX2gJ9sAesPzo3uTqcmlVXdpasTI8hChpBVw00pghKe3qTQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@kenjiuno/msgreader": {
|
||||||
|
"version": "1.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kenjiuno/msgreader/-/msgreader-1.28.0.tgz",
|
||||||
|
"integrity": "sha512-+iv2rWCGRHmX/3sBwXZzkThEuuywGJjnYsvxj6Kp1L/FDMICQcFrtqN+6MFrnh2d+umtfGtX904wxaYEDZ52MQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@kenjiuno/decompressrtf": "^0.1.3",
|
||||||
|
"iconv-lite": "^0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@kenjiuno/msgreader/node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.29.0",
|
"version": "1.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||||
@ -1709,6 +1742,15 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node-forge": {
|
||||||
|
"version": "1.3.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
|
||||||
|
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/oauth": {
|
"node_modules/@types/oauth": {
|
||||||
"version": "0.9.6",
|
"version": "0.9.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz",
|
||||||
@ -4518,6 +4560,15 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-forge": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
|
||||||
|
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/normalize-path": {
|
"node_modules/normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
@ -5314,6 +5365,19 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/selfsigned": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node-forge": "^1.3.0",
|
||||||
|
"node-forge": "^1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.4",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
"vapid-rotate": "tsx scripts/vapid-rotate.ts"
|
"vapid-rotate": "tsx scripts/vapid-rotate.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@kenjiuno/msgreader": "^1.28.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@novnc/novnc": "^1.6.0",
|
"@novnc/novnc": "^1.6.0",
|
||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
@ -51,6 +52,7 @@
|
|||||||
"pptxgenjs": "^4.0.1",
|
"pptxgenjs": "^4.0.1",
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
|
"selfsigned": "^2.4.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"ssh2": "^1.17.0",
|
"ssh2": "^1.17.0",
|
||||||
"undici": "^7.25.0",
|
"undici": "^7.25.0",
|
||||||
|
|||||||
@ -66,7 +66,7 @@ movements:
|
|||||||
- `result` がそのままユーザーに表示される最終出力。途中のメモや作業ログは入れない
|
- `result` がそのままユーザーに表示される最終出力。途中のメモや作業ログは入れない
|
||||||
- **ユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "確認したい内容", why_no_default: "デフォルトで進められない理由"})`
|
- **ユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "確認したい内容", why_no_default: "デフォルトで進められない理由"})`
|
||||||
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "失敗の理由"})`
|
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "失敗の理由"})`
|
||||||
allowed_tools: [Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, ReadExcel, ReadDocx, ReadPPTX, SQLite, Bash, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, BrowseWeb, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, ListPieces, GetPiece, CreatePiece, UpdatePiece, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, ReadToolDoc, UpdateDashboardWidget, 'mcp__*']
|
allowed_tools: [Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, ReadExcel, ReadDocx, ReadPPTX, ReadMsg, SQLite, Bash, XSearch, XUserPosts, XPostDetail, XTimeline, XFetchCardMedia, BrowseWeb, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, ListPieces, GetPiece, CreatePiece, UpdatePiece, SearchKnowledge, ListNamespaces, ListDocuments, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, ReadToolDoc, UpdateDashboardWidget, 'mcp__*']
|
||||||
# default_next is the engine-internal fallback for context overflow / ASK
|
# default_next is the engine-internal fallback for context overflow / ASK
|
||||||
# limit reached / SpawnSubTask unavailable. It is NOT exposed to the LLM.
|
# limit reached / SpawnSubTask unavailable. It is NOT exposed to the LLM.
|
||||||
default_next: COMPLETE
|
default_next: COMPLETE
|
||||||
|
|||||||
@ -62,7 +62,7 @@ movements:
|
|||||||
- **次の report へ**: `transition({next_step: "report"})`
|
- **次の report へ**: `transition({next_step: "report"})`
|
||||||
- **処理対象が特定できずユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
- **処理対象が特定できずユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||||
- **データが壊れている / 読み取れない / エラー発生で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
- **データが壊れている / 読み取れない / エラー発生で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools: [Read, Write, Bash, Glob, Grep, SQLite, WebSearch, WebFetch, DownloadFile, ReadExcel, ReadDocx, ReadPdf, ReadPPTX, SplitExcelSheets, PdfToImages, ReadImage, AnnotateImage, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, ReadToolDoc, 'mcp__*']
|
allowed_tools: [Read, Write, Bash, Glob, Grep, SQLite, WebSearch, WebFetch, DownloadFile, ReadExcel, ReadDocx, ReadPdf, ReadPPTX, ReadMsg, SplitExcelSheets, PdfToImages, ReadImage, AnnotateImage, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, ReadToolDoc, 'mcp__*']
|
||||||
default_next: report
|
default_next: report
|
||||||
rules:
|
rules:
|
||||||
- condition: output/ に結果を書き出した
|
- condition: output/ に結果を書き出した
|
||||||
|
|||||||
@ -121,7 +121,7 @@ movements:
|
|||||||
- **並列分解が効率的 → decompose へ**: `transition({next_step: "decompose"})`
|
- **並列分解が効率的 → decompose へ**: `transition({next_step: "decompose"})`
|
||||||
- **必須情報が不足し確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
- **必須情報が不足し確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||||
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
- **技術的失敗で打ち切り**: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, IngestDocument, IngestStatus, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
|
allowed_tools: [Read, Write, Bash, Glob, Grep, WebSearch, WebFetch, BrowseWeb, DownloadFile, ReadImage, AnnotateImage, ReadPdf, PdfToImages, ReadMsg, BatchReviewTextWithLLM, MergeReviewedResults, SearchPlaces, GetDirections, ReverseGeocode, GetYouTubeTranscript, SearchYouTube, SearchAmazon, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, IngestDocument, IngestStatus, SearchNotes, ReadNote, WriteNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache, 'mcp__*']
|
||||||
default_next: verify
|
default_next: verify
|
||||||
rules:
|
rules:
|
||||||
- condition: 2つ以上の独立したテーマがあり、並列分解が効率的と判断した
|
- condition: 2つ以上の独立したテーマがあり、並列分解が効率的と判断した
|
||||||
@ -177,7 +177,7 @@ movements:
|
|||||||
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
|
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
|
||||||
- 修正必要: `transition({next_step: "execute", summary: "差し戻し指摘"})` (上記形式で)
|
- 修正必要: `transition({next_step: "execute", summary: "差し戻し指摘"})` (上記形式で)
|
||||||
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools: [Read, Glob, Grep, WebSearch, WebFetch, ReadImage, AnnotateImage, ReadPdf, ReadExcel, ReadDocx, ReadPPTX, SearchNotes, ReadNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache]
|
allowed_tools: [Read, Glob, Grep, WebSearch, WebFetch, ReadImage, AnnotateImage, ReadPdf, ReadExcel, ReadDocx, ReadPPTX, ReadMsg, SearchNotes, ReadNote, SearchMicrosoftLearn, FetchMicrosoftLearn, SearchMicrosoftLearnCache, RefreshMicrosoftLearnCache]
|
||||||
default_next: COMPLETE
|
default_next: COMPLETE
|
||||||
rules:
|
rules:
|
||||||
- condition: 成果物がない、または内容に不足・誤りがある(追加質問への回答に検索根拠が不足している場合も含む)
|
- condition: 成果物がない、または内容に不足・誤りがある(追加質問への回答に検索根拠が不足している場合も含む)
|
||||||
|
|||||||
@ -38,6 +38,10 @@ movements:
|
|||||||
- テキストが抽出できた場合 → そのまま加工に進む
|
- テキストが抽出できた場合 → そのまま加工に進む
|
||||||
- 全ページが空テキスト(スキャン PDF)の場合 → PdfToImages でページ画像化し、ReadImage で内容を確認する(ReadImage は VLM 対応 worker でのみ利用可能)
|
- 全ページが空テキスト(スキャン PDF)の場合 → PdfToImages でページ画像化し、ReadImage で内容を確認する(ReadImage は VLM 対応 worker でのみ利用可能)
|
||||||
|
|
||||||
|
**Outlook メール (.msg)**:
|
||||||
|
- ReadMsg で件名・差出人・宛先・本文を取得。添付は input/ に保存される
|
||||||
|
- 保存された添付は ReadPdf / ReadExcel / ReadImage など種別ごとのツールで開く
|
||||||
|
|
||||||
## Office ファイルの加工方針
|
## Office ファイルの加工方針
|
||||||
|
|
||||||
Excel (.xlsx) の編集:
|
Excel (.xlsx) の編集:
|
||||||
@ -63,7 +67,7 @@ movements:
|
|||||||
- **追加情報が必要で同じ process を続行**: `transition({next_step: "process", summary: "..."})`
|
- **追加情報が必要で同じ process を続行**: `transition({next_step: "process", summary: "..."})`
|
||||||
- **対象が特定できずユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
- **対象が特定できずユーザー確認が必要**: `complete({status: "needs_user_input", missing_info: "...", why_no_default: "..."})`
|
||||||
- **読み取り不能・対応外フォーマット等の技術的失敗**: `complete({status: "aborted", abort_reason: "..."})`
|
- **読み取り不能・対応外フォーマット等の技術的失敗**: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools: [Read, Write, Bash, Glob, Grep, ReadExcel, ReadDocx, ReadPdf, ReadPPTX, SplitExcelSheets, SplitDocxSections, PdfToImages, ReadImage, WebSearch, WebFetch, DownloadFile, SQLite, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, ReadToolDoc, 'mcp__*']
|
allowed_tools: [Read, Write, Bash, Glob, Grep, ReadExcel, ReadDocx, ReadPdf, ReadPPTX, ReadMsg, SplitExcelSheets, SplitDocxSections, PdfToImages, ReadImage, WebSearch, WebFetch, DownloadFile, SQLite, TranscribeAudio, SearchKnowledge, ListNamespaces, ListDocuments, ReadToolDoc, 'mcp__*']
|
||||||
default_next: verify
|
default_next: verify
|
||||||
rules:
|
rules:
|
||||||
- condition: output/ に成果物を書き出した(または既存ファイルを編集した)
|
- condition: output/ に成果物を書き出した(または既存ファイルを編集した)
|
||||||
@ -112,7 +116,7 @@ movements:
|
|||||||
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
|
- 合格: `complete({status: "success", result: "ユーザー向け最終回答"})`
|
||||||
- 修正必要: `transition({next_step: "process", summary: "差し戻し指摘"})` (上記形式で)
|
- 修正必要: `transition({next_step: "process", summary: "差し戻し指摘"})` (上記形式で)
|
||||||
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
- 技術的失敗: `complete({status: "aborted", abort_reason: "..."})`
|
||||||
allowed_tools: [Read, Glob, Grep, ReadPdf, ReadImage, ReadExcel, ReadDocx, ReadPPTX, ReadToolDoc]
|
allowed_tools: [Read, Glob, Grep, ReadPdf, ReadImage, ReadExcel, ReadDocx, ReadPPTX, ReadMsg, ReadToolDoc]
|
||||||
default_next: COMPLETE
|
default_next: COMPLETE
|
||||||
rules:
|
rules:
|
||||||
- condition: 成果物がない、または内容に不足・誤りがある
|
- condition: 成果物がない、または内容に不足・誤りがある
|
||||||
|
|||||||
@ -5,6 +5,53 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|||||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
PID_FILE="$PROJECT_DIR/.server.pid"
|
PID_FILE="$PROJECT_DIR/.server.pid"
|
||||||
LOG_FILE="$PROJECT_DIR/logs/server.log"
|
LOG_FILE="$PROJECT_DIR/logs/server.log"
|
||||||
|
|
||||||
|
# Optional project-root .env (gitignored; setup.sh writes credentials here,
|
||||||
|
# operators can add e.g. HOST=0.0.0.0 for reverse-proxy deployments so a bare
|
||||||
|
# `server.sh restart` keeps the bind address). Lines are `KEY=value` or
|
||||||
|
# `export KEY='value'`. Precedence: explicit environment > .env > defaults,
|
||||||
|
# so `HOST=127.0.0.1 scripts/server.sh restart` still overrides the file.
|
||||||
|
if [[ -f "$PROJECT_DIR/.env" ]]; then
|
||||||
|
# Values are parsed LITERALLY (no eval/source): a value is either bare, or
|
||||||
|
# single-quoted in setup.sh's format ('\'' encodes a literal quote), or
|
||||||
|
# double-quoted (quotes stripped, contents kept literal — no $ expansion).
|
||||||
|
# One KEY=value per line; full-line comments only.
|
||||||
|
#
|
||||||
|
# Keys this loader itself set from .env are tracked space-delimited (keys are
|
||||||
|
# validated identifiers so this is unambiguous — no Bash-4 associative
|
||||||
|
# arrays, macOS ships bash 3.2). Only EXTERNAL pre-set environment is
|
||||||
|
# protected; duplicate keys within .env keep normal source semantics (last
|
||||||
|
# line wins) because earlier lines are recorded here and may be overridden.
|
||||||
|
_envfile_set=" "
|
||||||
|
_q=\'
|
||||||
|
_esc=$'\x01'
|
||||||
|
while IFS= read -r _line || [[ -n "$_line" ]]; do
|
||||||
|
_line="${_line#"${_line%%[![:space:]]*}"}" # ltrim
|
||||||
|
[[ -z "$_line" || "$_line" == \#* ]] && continue
|
||||||
|
_kv="${_line#export }"
|
||||||
|
_key="${_kv%%=*}"
|
||||||
|
[[ "$_key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
|
||||||
|
[[ "$_kv" == *=* ]] || continue
|
||||||
|
_val="${_kv#*=}"
|
||||||
|
case "$_val" in
|
||||||
|
"$_q"*)
|
||||||
|
_val="${_val//${_q}\\${_q}${_q}/${_esc}}" # '\'' -> literal-quote marker
|
||||||
|
_val="${_val//${_q}/}" # drop quoting quotes
|
||||||
|
_val="${_val//${_esc}/${_q}}" # restore literal quotes
|
||||||
|
;;
|
||||||
|
\"*\")
|
||||||
|
_val="${_val#\"}"
|
||||||
|
_val="${_val%\"}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [[ "$_envfile_set" == *" $_key "* || -z "${!_key+x}" ]]; then
|
||||||
|
export "$_key=$_val"
|
||||||
|
_envfile_set="${_envfile_set}${_key} "
|
||||||
|
fi
|
||||||
|
done < "$PROJECT_DIR/.env"
|
||||||
|
unset _line _kv _key _val _envfile_set _q _esc
|
||||||
|
fi
|
||||||
|
|
||||||
PORT="${PORT:-9876}"
|
PORT="${PORT:-9876}"
|
||||||
|
|
||||||
cd "$PROJECT_DIR"
|
cd "$PROJECT_DIR"
|
||||||
|
|||||||
@ -130,6 +130,15 @@ path.write_text(text.replace(needle, replacement))
|
|||||||
PY
|
PY
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
python3 - <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
p = Path("config.yaml")
|
||||||
|
text = p.read_text()
|
||||||
|
if "\nserver:" not in text:
|
||||||
|
text += "\nserver:\n tls:\n enabled: true\n"
|
||||||
|
p.write_text(text)
|
||||||
|
PY
|
||||||
|
|
||||||
echo " config.yaml を生成/更新しました"
|
echo " config.yaml を生成/更新しました"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
@ -191,7 +200,8 @@ chmod 600 "${ENV_FILE}"
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " 認証情報を ${ENV_FILE} (権限 0600) に書き出しました。"
|
echo " 認証情報を ${ENV_FILE} (権限 0600) に書き出しました。"
|
||||||
echo " 起動前に以下で読み込んでください:"
|
echo " scripts/server.sh は起動時に ${ENV_FILE} を自動で読み込みます。"
|
||||||
|
echo " 手動起動 (npm start 等) の場合のみ、事前に読み込んでください:"
|
||||||
echo ""
|
echo ""
|
||||||
echo " source ${ENV_FILE}"
|
echo " source ${ENV_FILE}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { IncomingMessage, Server as HttpServer } from 'node:http';
|
import type { IncomingMessage, Server as HttpServer } from 'node:http';
|
||||||
|
import type { Server as HttpsServer } from 'node:https';
|
||||||
import type { Socket } from 'node:net';
|
import type { Socket } from 'node:net';
|
||||||
import { WebSocketServer, type WebSocket } from 'ws';
|
import { WebSocketServer, type WebSocket } from 'ws';
|
||||||
import { Router, json, type Request, type Response } from 'express';
|
import { Router, json, type Request, type Response } from 'express';
|
||||||
@ -81,7 +82,9 @@ const PATH_RE = /^\/+api\/local\/tasks\/([^/]+)\/console\/ws$/;
|
|||||||
* (the client gets a 1006 abnormal close) so we don't leak failure
|
* (the client gets a 1006 abnormal close) so we don't leak failure
|
||||||
* reasons over the upgrade channel. The reason is always logged.
|
* reasons over the upgrade channel. The reason is always logged.
|
||||||
*/
|
*/
|
||||||
export function attachConsoleWs(server: HttpServer, deps: ConsoleWsDeps): void {
|
// Both http.Server and https.Server emit the 'upgrade' event used for WSS,
|
||||||
|
// so either type is a valid host for the console WebSocket upgrade handler.
|
||||||
|
export function attachConsoleWs(server: HttpServer | HttpsServer, deps: ConsoleWsDeps): void {
|
||||||
const wss = new WebSocketServer({ noServer: true });
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
server.on('upgrade', async (req, socket, head) => {
|
server.on('upgrade', async (req, socket, head) => {
|
||||||
|
|||||||
@ -37,7 +37,11 @@ function makeUser(overrides: Partial<Express.User> = {}): Express.User {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeApp(repo: Repository, user?: Express.User): express.Application {
|
function makeApp(
|
||||||
|
repo: Repository,
|
||||||
|
user?: Express.User,
|
||||||
|
opts: { authActive?: boolean } = {},
|
||||||
|
): express.Application {
|
||||||
const app = express();
|
const app = express();
|
||||||
if (user) {
|
if (user) {
|
||||||
app.use((req, _res, next) => {
|
app.use((req, _res, next) => {
|
||||||
@ -45,7 +49,7 @@ function makeApp(repo: Repository, user?: Express.User): express.Application {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
mountLocalFilesApi(app, repo);
|
mountLocalFilesApi(app, repo, { authActive: opts.authActive ?? true });
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,6 +59,7 @@ beforeEach(() => {
|
|||||||
mkdirSync(join(ws, 'output', 'sub'), { recursive: true });
|
mkdirSync(join(ws, 'output', 'sub'), { recursive: true });
|
||||||
writeFileSync(join(ws, 'input', 'data.csv'), 'a,b\n1,2\n');
|
writeFileSync(join(ws, 'input', 'data.csv'), 'a,b\n1,2\n');
|
||||||
writeFileSync(join(ws, 'output', 'report.md'), '# report');
|
writeFileSync(join(ws, 'output', 'report.md'), '# report');
|
||||||
|
writeFileSync(join(ws, 'output', 'report.html'), '<!doctype html><script>window.__ran = true</script><h1>report</h1>');
|
||||||
writeFileSync(join(ws, 'output', 'sub', 'nested.txt'), 'nested');
|
writeFileSync(join(ws, 'output', 'sub', 'nested.txt'), 'nested');
|
||||||
// A file just outside the workspace that traversal must never reach.
|
// A file just outside the workspace that traversal must never reach.
|
||||||
writeFileSync(join(ws, '..', `outside-${process.pid}.txt`), 'secret');
|
writeFileSync(join(ws, '..', `outside-${process.pid}.txt`), 'secret');
|
||||||
@ -153,6 +158,80 @@ describe('GET /api/local/tasks/:taskId/files/raw', () => {
|
|||||||
expect(res.headers['content-type']).toContain('markdown');
|
expect(res.headers['content-type']).toContain('markdown');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sandboxes raw HTML by default', async () => {
|
||||||
|
const res = await request(makeApp(makeRepo(), makeUser()))
|
||||||
|
.get('/api/local/tasks/1/files/raw?section=output&path=report.html');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers['content-security-policy']).toBe('sandbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the task OWNER to open trusted raw HTML without the sandbox header', async () => {
|
||||||
|
const res = await request(makeApp(makeRepo(), makeUser()))
|
||||||
|
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers['content-security-policy']).toBeUndefined();
|
||||||
|
expect(res.headers['content-type']).toContain('html');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps trusted raw HTML sandboxed even for an admin on another user's task (user→admin lure)", async () => {
|
||||||
|
const res = await request(makeApp(makeRepo(), makeUser({ id: 'admin-9', role: 'admin' })))
|
||||||
|
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers['content-security-policy']).toBe('sandbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps trusted raw HTML sandboxed for a NON-owner viewer of a shared task', async () => {
|
||||||
|
const repo = makeRepo({
|
||||||
|
getLocalTask: vi.fn().mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
ownerId: 'user-1',
|
||||||
|
visibility: 'public',
|
||||||
|
workspacePath: ws,
|
||||||
|
}),
|
||||||
|
} as Partial<Repository>);
|
||||||
|
const res = await request(makeApp(repo, makeUser({ id: 'user-2' })))
|
||||||
|
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers['content-security-policy']).toBe('sandbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps trusted raw HTML sandboxed for an ownerless task even when authenticated', async () => {
|
||||||
|
const repo = makeRepo({
|
||||||
|
getLocalTask: vi.fn().mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
ownerId: null,
|
||||||
|
visibility: 'public',
|
||||||
|
workspacePath: ws,
|
||||||
|
}),
|
||||||
|
} as Partial<Repository>);
|
||||||
|
const res = await request(makeApp(repo, makeUser()))
|
||||||
|
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers['content-security-policy']).toBe('sandbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps trusted raw HTML sandboxed when auth is on but no user is present', async () => {
|
||||||
|
const res = await request(makeApp(makeRepo()))
|
||||||
|
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers['content-security-policy']).toBe('sandbox');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows trusted raw HTML in no-auth mode (sole operator owns every task)', async () => {
|
||||||
|
const res = await request(makeApp(makeRepo(), undefined, { authActive: false }))
|
||||||
|
.get('/api/local/tasks/1/files/raw?section=output&path=report.html&trusted=1');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers['content-security-policy']).toBeUndefined();
|
||||||
|
expect(res.headers['content-type']).toContain('html');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still sandboxes non-HTML in no-auth mode even with trusted=1', async () => {
|
||||||
|
const res = await request(makeApp(makeRepo(), undefined, { authActive: false }))
|
||||||
|
.get('/api/local/tasks/1/files/raw?section=output&path=report.md&trusted=1');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers['content-security-policy']).toBe('sandbox');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects traversal reads with 400 and never serves outside files', async () => {
|
it('rejects traversal reads with 400 and never serves outside files', async () => {
|
||||||
const res = await request(makeApp(makeRepo(), makeUser()))
|
const res = await request(makeApp(makeRepo(), makeUser()))
|
||||||
.get(`/api/local/tasks/1/files/raw?section=input&path=..%2F..%2Foutside-${process.pid}.txt`);
|
.get(`/api/local/tasks/1/files/raw?section=input&path=..%2F..%2Foutside-${process.pid}.txt`);
|
||||||
|
|||||||
@ -6,7 +6,20 @@ import { logger } from '../logger.js';
|
|||||||
import { parseTaskId } from './validation.js';
|
import { parseTaskId } from './validation.js';
|
||||||
import { ensurePathWithin, isPathEscapeError, serializeLocalFileEntry, checkTaskOwnership, canViewTask, setUntrustedFileResponseHeaders } from './local-api-helpers.js';
|
import { ensurePathWithin, isPathEscapeError, serializeLocalFileEntry, checkTaskOwnership, canViewTask, setUntrustedFileResponseHeaders } from './local-api-helpers.js';
|
||||||
|
|
||||||
export function mountLocalFilesApi(app: Application, repo: Repository): void {
|
export interface LocalFilesApiOptions {
|
||||||
|
/** Whether the auth subsystem is wired. When false (no-auth single-user
|
||||||
|
* deployment) there is no req.user, so the sole local operator owns every
|
||||||
|
* task and is allowed to open their own generated HTML with trusted=1.
|
||||||
|
* Defaults to true (owner identity comes from req.user). */
|
||||||
|
authActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mountLocalFilesApi(
|
||||||
|
app: Application,
|
||||||
|
repo: Repository,
|
||||||
|
opts: LocalFilesApiOptions = {},
|
||||||
|
): void {
|
||||||
|
const authActive = opts.authActive ?? true;
|
||||||
|
|
||||||
app.get('/api/local/tasks/:taskId/files', async (req: Request, res: Response) => {
|
app.get('/api/local/tasks/:taskId/files', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
@ -125,7 +138,23 @@ export function mountLocalFilesApi(app: Application, repo: Repository): void {
|
|||||||
res.status(400).json({ error: 'path must point to a file' });
|
res.status(400).json({ error: 'path must point to a file' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// trusted=1 drops the CSP sandbox so the owner's own generated HTML can
|
||||||
|
// run on the app origin. STRICTLY owner-only — self-XSS at worst:
|
||||||
|
// - org/public visibility lets other users VIEW the task, but serving
|
||||||
|
// someone else's HTML unsandboxed here would be stored XSS against
|
||||||
|
// the viewer;
|
||||||
|
// - admins are excluded too: another user's HTML running in an ADMIN
|
||||||
|
// session would be a user→admin privilege-escalation lure.
|
||||||
|
// No-auth single-user mode has no req.user; the sole operator owns every
|
||||||
|
// task, so they are the owner for this purpose (self-XSS only — there is
|
||||||
|
// no second principal to attack).
|
||||||
|
const trustedAllowed = authActive
|
||||||
|
? !!viewer && task.ownerId != null && viewer.id === task.ownerId
|
||||||
|
: true;
|
||||||
|
const trustedHtml = req.query.trusted === '1' && /\.html?$/i.test(filePath) && trustedAllowed;
|
||||||
|
if (!trustedHtml) {
|
||||||
setUntrustedFileResponseHeaders(res);
|
setUntrustedFileResponseHeaders(res);
|
||||||
|
}
|
||||||
res.type(extname(filePath) || 'application/octet-stream');
|
res.type(extname(filePath) || 'application/octet-stream');
|
||||||
res.send(readFileSync(filePath));
|
res.send(readFileSync(filePath));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { tmpdir } from 'os';
|
|||||||
import { Repository, localTaskRepoName } from '../db/repository.js';
|
import { Repository, localTaskRepoName } from '../db/repository.js';
|
||||||
import { BrowserSessionRepo } from '../db/browser-session-repo.js';
|
import { BrowserSessionRepo } from '../db/browser-session-repo.js';
|
||||||
import { mountLocalTasksApi } from './local-tasks-api.js';
|
import { mountLocalTasksApi } from './local-tasks-api.js';
|
||||||
|
import { buildLocalConversationContext } from '../engine/local-context.js';
|
||||||
|
|
||||||
describe('POST /api/local/tasks with visibility', () => {
|
describe('POST /api/local/tasks with visibility', () => {
|
||||||
let tempDir = '';
|
let tempDir = '';
|
||||||
@ -888,6 +889,41 @@ describe('POST /api/local/tasks/:id/continue', () => {
|
|||||||
expect(handoff?.body).toContain('ssh-ops');
|
expect(handoff?.body).toContain('ssh-ops');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists the switch instruction as the latest user request so the agent follows it', async () => {
|
||||||
|
const { task } = await setupTaskWithTerminalJob();
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/local/tasks/${task.id}/continue`)
|
||||||
|
.send({ piece: 'ssh-ops', instruction: 'use output/manual.md to set up foo' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const comments = await repo.listLocalTaskComments(task.id);
|
||||||
|
// The switch text must exist as a user 'request' comment...
|
||||||
|
const userRequests = comments.filter((c) => c.author === 'user' && c.kind === 'request');
|
||||||
|
const switchComment = userRequests.find((c) => c.body === 'use output/manual.md to set up foo');
|
||||||
|
expect(switchComment).toBeTruthy();
|
||||||
|
// ...and be the LATEST user instruction (newer than the original 'b' body and
|
||||||
|
// the prior agent result), which is what buildLocalConversationContext keys on.
|
||||||
|
const userInstructionKinds = ['comment', 'request', 'interjection'];
|
||||||
|
const latestUserInstruction = [...comments]
|
||||||
|
.reverse()
|
||||||
|
.find((c) => c.author === 'user' && userInstructionKinds.includes(c.kind));
|
||||||
|
expect(latestUserInstruction?.body).toBe('use output/manual.md to set up foo');
|
||||||
|
|
||||||
|
// End-to-end: feeding the resulting comments + the continued job's
|
||||||
|
// instruction into the worker's context builder must put the switch text
|
||||||
|
// under the active "## タスク" heading, NOT the demoted
|
||||||
|
// "## オリジナルタスク (参考、対応済みの可能性あり)" slot that caused the
|
||||||
|
// agent to re-follow earlier instructions.
|
||||||
|
const ctx = buildLocalConversationContext({
|
||||||
|
comments,
|
||||||
|
jobInstruction: 'use output/manual.md to set up foo',
|
||||||
|
inputFiles: [],
|
||||||
|
outputFiles: [],
|
||||||
|
});
|
||||||
|
expect(ctx).toContain('## タスク');
|
||||||
|
expect(ctx).toContain('use output/manual.md to set up foo');
|
||||||
|
expect(ctx).not.toContain('## オリジナルタスク');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns 409 job_in_progress when prev job is running', async () => {
|
it('returns 409 job_in_progress when prev job is running', async () => {
|
||||||
const { task } = await setupTaskWithTerminalJob({ status: 'running' });
|
const { task } = await setupTaskWithTerminalJob({ status: 'running' });
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
|
|||||||
@ -8,11 +8,12 @@ import { resolveJobScheduling } from '../scheduling.js';
|
|||||||
import { parseTaskId, validateCreateTaskBody, validateCommentBody, validateFeedbackBody } from './validation.js';
|
import { parseTaskId, validateCreateTaskBody, validateCommentBody, validateFeedbackBody } from './validation.js';
|
||||||
import { getLocalWorkspacePath, checkTaskOwnership, canViewTask } from './local-api-helpers.js';
|
import { getLocalWorkspacePath, checkTaskOwnership, canViewTask } from './local-api-helpers.js';
|
||||||
import { jobEventBus, type JobStreamEvent } from './job-events.js';
|
import { jobEventBus, type JobStreamEvent } from './job-events.js';
|
||||||
|
import { buildTitleFallback } from '../title-generation.js';
|
||||||
|
|
||||||
export interface LocalTasksApiOptions {
|
export interface LocalTasksApiOptions {
|
||||||
repo: Repository;
|
repo: Repository;
|
||||||
worktreeDir?: string;
|
worktreeDir?: string;
|
||||||
generateTitle?: (body: string) => Promise<string>;
|
generateTitle?: (body: string, ownerId?: string) => Promise<string>;
|
||||||
selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
|
selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
|
||||||
/**
|
/**
|
||||||
* Server-side validator for piece names accepted by the
|
* Server-side validator for piece names accepted by the
|
||||||
@ -126,28 +127,23 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
|||||||
browserSessionProfileId = n;
|
browserSessionProfileId = n;
|
||||||
}
|
}
|
||||||
|
|
||||||
let taskTitle = (body.title ?? '').trim();
|
const userTitle = (body.title ?? '').trim();
|
||||||
const rawPiece = (body.piece ?? 'auto').trim();
|
const rawPiece = (body.piece ?? 'auto').trim();
|
||||||
const attachmentNames = (body.attachments ?? []).map((a: { name?: string }) => a.name).filter(Boolean) as string[];
|
const attachmentNames = (body.attachments ?? []).map((a: { name?: string }) => a.name).filter(Boolean) as string[];
|
||||||
|
|
||||||
// タイトル生成と piece 分類を並列実行
|
// Title is NOT generated by an LLM at creation time anymore — that fired a
|
||||||
const [generatedTitle, autoSelectedPiece] = await Promise.all([
|
// second concurrent LLM request per task and churned gateway backend
|
||||||
// タイトル生成
|
// slots. Instead we set a cheap synchronous fallback now, and the agent
|
||||||
(!taskTitle && opts.generateTitle)
|
// upgrades it during the run by deriving from the Mission Brief goal
|
||||||
? Promise.race([
|
// (see Repository.updateMissionBriefSync). On-demand AI regeneration is
|
||||||
opts.generateTitle(body.body.trim()),
|
// available via POST /api/local/tasks/:id/regenerate-title.
|
||||||
new Promise<string>((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000)),
|
const autoSelectedPiece = (rawPiece === 'auto' && opts.selectPiece)
|
||||||
]).catch((e: unknown) => { logger.warn(`Title generation failed: ${e}`); return ''; })
|
? await opts.selectPiece(body.body.trim(), attachmentNames, (req.user as Express.User | undefined)?.id)
|
||||||
: Promise.resolve(''),
|
.catch((e: unknown) => { logger.warn(`Piece classification failed: ${e}`); return 'chat'; })
|
||||||
// piece 分類('auto' の場合のみ); userId を渡し per-user カタログを使用
|
: rawPiece;
|
||||||
(rawPiece === 'auto' && opts.selectPiece)
|
|
||||||
? opts.selectPiece(body.body.trim(), attachmentNames, (req.user as Express.User | undefined)?.id).catch((e: unknown) => { logger.warn(`Piece classification failed: ${e}`); return 'chat'; })
|
|
||||||
: Promise.resolve(rawPiece),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!taskTitle) {
|
const taskTitle = userTitle || buildTitleFallback(body.body.trim());
|
||||||
taskTitle = generatedTitle || body.body.trim().slice(0, 40).replace(/\n/g, ' ');
|
const titleSource: 'auto' | 'user' = userTitle ? 'user' : 'auto';
|
||||||
}
|
|
||||||
const piece = autoSelectedPiece;
|
const piece = autoSelectedPiece;
|
||||||
const profile = body.profile ?? 'auto';
|
const profile = body.profile ?? 'auto';
|
||||||
const outputFormat = body.outputFormat ?? 'markdown';
|
const outputFormat = body.outputFormat ?? 'markdown';
|
||||||
@ -168,6 +164,7 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
|||||||
|
|
||||||
const task = await repo.createLocalTask({
|
const task = await repo.createLocalTask({
|
||||||
title: taskTitle,
|
title: taskTitle,
|
||||||
|
titleSource,
|
||||||
body: body.body.trim(),
|
body: body.body.trim(),
|
||||||
pieceName: piece,
|
pieceName: piece,
|
||||||
profile,
|
profile,
|
||||||
@ -406,7 +403,18 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
|||||||
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
|
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
|
||||||
if (!checkTaskOwnership(req, res, task)) return;
|
if (!checkTaskOwnership(req, res, task)) return;
|
||||||
|
|
||||||
const updates: { visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null } = {};
|
const updates: { title?: string; titleSource?: 'user'; visibility?: 'private' | 'org' | 'public'; visibilityScopeOrgId?: string | null } = {};
|
||||||
|
if (req.body.title !== undefined) {
|
||||||
|
if (typeof req.body.title !== 'string') {
|
||||||
|
res.status(400).json({ error: 'title must be a string' }); return;
|
||||||
|
}
|
||||||
|
const trimmed = req.body.title.trim();
|
||||||
|
if (!trimmed) { res.status(400).json({ error: 'title must not be empty' }); return; }
|
||||||
|
if (trimmed.length > 200) { res.status(400).json({ error: 'title must be 200 characters or less' }); return; }
|
||||||
|
// Manual edit pins the title: the agent never auto-overwrites a user title.
|
||||||
|
updates.title = trimmed;
|
||||||
|
updates.titleSource = 'user';
|
||||||
|
}
|
||||||
if (req.body.visibility !== undefined) {
|
if (req.body.visibility !== undefined) {
|
||||||
const v = req.body.visibility;
|
const v = req.body.visibility;
|
||||||
if (!['private', 'org', 'public'].includes(v)) {
|
if (!['private', 'org', 'public'].includes(v)) {
|
||||||
@ -443,6 +451,41 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// On-demand AI title regeneration. Unlike the old creation-time path this
|
||||||
|
// only fires when the user explicitly asks (a button), so it never adds a
|
||||||
|
// concurrent LLM request to the task-creation hot path. Owner/admin only.
|
||||||
|
app.post('/api/local/tasks/:taskId/regenerate-title', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const taskId = parseTaskId(req.params.taskId);
|
||||||
|
if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; }
|
||||||
|
const task = await repo.getLocalTask(taskId, { viewer: req.user as Express.User | undefined });
|
||||||
|
if (!checkTaskOwnership(req, res, task)) return;
|
||||||
|
if (!opts.generateTitle) { res.status(503).json({ error: 'Title generation is not configured' }); return; }
|
||||||
|
|
||||||
|
let title = '';
|
||||||
|
try {
|
||||||
|
title = await Promise.race([
|
||||||
|
// Ownerless (no-auth) tasks attribute to 'local', matching the
|
||||||
|
// worker/piece-runner convention (ownerId ?? 'local').
|
||||||
|
opts.generateTitle(task!.body, task!.ownerId ?? 'local'),
|
||||||
|
new Promise<string>((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000)),
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`Title regeneration failed (task=${taskId}): ${e}`);
|
||||||
|
res.status(502).json({ error: 'Title generation failed' }); return;
|
||||||
|
}
|
||||||
|
// Empty model output is not an error: fall back to the cheap synchronous
|
||||||
|
// title so the button always yields something (matching the old creation
|
||||||
|
// path's behaviour).
|
||||||
|
title = (title ?? '').trim() || buildTitleFallback(task!.body);
|
||||||
|
await repo.updateLocalTask(taskId, { title, titleSource: 'agent' });
|
||||||
|
res.json({ title });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Regenerate title API error: ${err}`);
|
||||||
|
res.status(500).json({ error: 'Failed to regenerate title' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.delete('/api/local/tasks/:taskId', async (req: Request, res: Response) => {
|
app.delete('/api/local/tasks/:taskId', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const taskId = parseTaskId(req.params.taskId);
|
const taskId = parseTaskId(req.params.taskId);
|
||||||
@ -556,6 +599,15 @@ export function mountLocalTasksApi(app: Application, opts: LocalTasksApiOptions)
|
|||||||
|
|
||||||
await repo.updateLocalTask(taskId, { pieceName: piece });
|
await repo.updateLocalTask(taskId, { pieceName: piece });
|
||||||
|
|
||||||
|
// Persist the switch-time instruction as a user request. Without this it
|
||||||
|
// lives only in job.instruction, and buildLocalConversationContext picks
|
||||||
|
// the *latest user comment* as the current instruction — so a stale older
|
||||||
|
// comment would win and the switch text would be demoted to the "original
|
||||||
|
// task (possibly already handled)" slot, making the agent re-follow prior
|
||||||
|
// instructions instead of the new one. Mirrors the create path, which
|
||||||
|
// also persists the body as a 'request' comment.
|
||||||
|
await repo.addLocalTaskComment(taskId, 'user', instruction.trim(), 'request');
|
||||||
|
|
||||||
// Surface the handoff in the timeline so the user (and the LLM, when
|
// Surface the handoff in the timeline so the user (and the LLM, when
|
||||||
// it later inspects task comments) can see when piece switches happened.
|
// it later inspects task comments) can see when piece switches happened.
|
||||||
await repo.addLocalTaskComment(
|
await repo.addLocalTaskComment(
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { fileURLToPath } from 'url';
|
|||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import type { Server } from 'http';
|
import type { Server } from 'http';
|
||||||
|
import type { Server as HttpsServer } from 'https';
|
||||||
import type { SessionManager, BrowserSession } from '../engine/browser-session.js';
|
import type { SessionManager, BrowserSession } from '../engine/browser-session.js';
|
||||||
import type { UpgradeAuthChecker } from './auth.js';
|
import type { UpgradeAuthChecker } from './auth.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
@ -78,7 +79,9 @@ export function createNovncRouter(): Router {
|
|||||||
* - authenticateUpgrade 未設定 (dev モード) は session 存在確認だけで通す
|
* - authenticateUpgrade 未設定 (dev モード) は session 存在確認だけで通す
|
||||||
*/
|
*/
|
||||||
export function setupNovncWebSocketProxy(
|
export function setupNovncWebSocketProxy(
|
||||||
server: Server,
|
// Both http.Server and https.Server emit the 'upgrade' event used for WSS,
|
||||||
|
// so either type works here as a WebSocket proxy host.
|
||||||
|
server: Server | HttpsServer,
|
||||||
getSessionManager: () => SessionManager | null,
|
getSessionManager: () => SessionManager | null,
|
||||||
authenticateUpgrade?: UpgradeAuthChecker,
|
authenticateUpgrade?: UpgradeAuthChecker,
|
||||||
authorizeSession?: NovncSessionAuthorizer,
|
authorizeSession?: NovncSessionAuthorizer,
|
||||||
|
|||||||
81
src/bridge/server-tls-listener.test.ts
Normal file
81
src/bridge/server-tls-listener.test.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { createServer as createHttpsServer, type Server } from 'https';
|
||||||
|
import { request as httpsRequest } from 'https';
|
||||||
|
import { mkdtempSync, rmSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { resolveTlsOptions } from '../net/tls-options.js';
|
||||||
|
import { SERVER_TLS_DEFAULTS } from '../server/config.js';
|
||||||
|
|
||||||
|
describe('native HTTPS listener (self-signed)', () => {
|
||||||
|
let server: Server | undefined;
|
||||||
|
let dir: string | undefined;
|
||||||
|
afterEach(async () => {
|
||||||
|
if (server) await new Promise<void>((r) => server!.close(() => r()));
|
||||||
|
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||||
|
server = undefined;
|
||||||
|
dir = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes a TLS>=1.2 handshake and serves the app over https', { timeout: 15000 }, async () => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'tls-listener-'));
|
||||||
|
const resolved = resolveTlsOptions({ ...SERVER_TLS_DEFAULTS, enabled: true, selfSignedDir: dir });
|
||||||
|
server = createHttpsServer(
|
||||||
|
{ cert: resolved.cert, key: resolved.key, minVersion: resolved.minVersion },
|
||||||
|
(_req, res) => {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end('ok');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await new Promise<void>((r) => server!.listen(0, '127.0.0.1', r));
|
||||||
|
const addr = server.address();
|
||||||
|
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
||||||
|
|
||||||
|
const result = await new Promise<{ code: number; body: string; proto: string | null }>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
const req = httpsRequest(
|
||||||
|
{ host: '127.0.0.1', port, path: '/', rejectUnauthorized: false },
|
||||||
|
(res) => {
|
||||||
|
// Capture protocol before the socket is torn down (socket may be
|
||||||
|
// null by the time 'end' fires so we snapshot it on 'response').
|
||||||
|
const proto = (res.socket as import('tls').TLSSocket | null)?.getProtocol?.() ?? null;
|
||||||
|
let d = '';
|
||||||
|
res.on('data', (c) => (d += c));
|
||||||
|
res.on('end', () =>
|
||||||
|
resolve({ code: res.statusCode ?? 0, body: d, proto }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(result.code).toBe(200);
|
||||||
|
expect(result.body).toBe('ok');
|
||||||
|
expect(['TLSv1.2', 'TLSv1.3']).toContain(result.proto);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a strict client rejects the self-signed cert', async () => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'tls-listener-strict-'));
|
||||||
|
const resolved = resolveTlsOptions({ ...SERVER_TLS_DEFAULTS, enabled: true, selfSignedDir: dir });
|
||||||
|
server = createHttpsServer(
|
||||||
|
{ cert: resolved.cert, key: resolved.key, minVersion: resolved.minVersion },
|
||||||
|
(_req, res) => {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end('ok');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await new Promise<void>((r) => server!.listen(0, '127.0.0.1', r));
|
||||||
|
const addr = server.address();
|
||||||
|
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
||||||
|
const outcome = await new Promise<string>((resolve) => {
|
||||||
|
const req = httpsRequest(
|
||||||
|
{ host: '127.0.0.1', port, path: '/', rejectUnauthorized: true },
|
||||||
|
() => resolve('UNEXPECTED_OK'),
|
||||||
|
);
|
||||||
|
req.on('error', (e) => resolve('rejected:' + (e as NodeJS.ErrnoException).code));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
expect(outcome).toMatch(/^rejected:/);
|
||||||
|
});
|
||||||
|
});
|
||||||
26
src/bridge/server.tls.test.ts
Normal file
26
src/bridge/server.tls.test.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { computeEffectiveSecureCookie, shouldWarnDoubleTls } from './server.js';
|
||||||
|
|
||||||
|
describe('computeEffectiveSecureCookie', () => {
|
||||||
|
it('is true when secure_cookie is on (proxy mode)', () => {
|
||||||
|
expect(computeEffectiveSecureCookie(true, false)).toBe(true);
|
||||||
|
});
|
||||||
|
it('is true when native TLS is on even if secure_cookie is off', () => {
|
||||||
|
expect(computeEffectiveSecureCookie(false, true)).toBe(true);
|
||||||
|
});
|
||||||
|
it('is false when neither', () => {
|
||||||
|
expect(computeEffectiveSecureCookie(false, false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shouldWarnDoubleTls', () => {
|
||||||
|
it('warns when native TLS and secure_cookie (proxy signal) are both on', () => {
|
||||||
|
expect(shouldWarnDoubleTls(true, true)).toBe(true);
|
||||||
|
});
|
||||||
|
it('does not warn for a plain native-TLS install (secure_cookie off)', () => {
|
||||||
|
expect(shouldWarnDoubleTls(true, false)).toBe(false);
|
||||||
|
});
|
||||||
|
it('does not warn when TLS is disabled', () => {
|
||||||
|
expect(shouldWarnDoubleTls(false, true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -16,6 +16,7 @@ import { mountBrandingApi, resolveBranding } from './branding-api.js';
|
|||||||
import { createBrowserApi } from './browser-api.js';
|
import { createBrowserApi } from './browser-api.js';
|
||||||
import { createBrowserSessionApi } from './browser-session-api.js';
|
import { createBrowserSessionApi } from './browser-session-api.js';
|
||||||
import { createSubtaskActivityRouter } from './subtask-activity-api.js';
|
import { createSubtaskActivityRouter } from './subtask-activity-api.js';
|
||||||
|
import { createUsageRouter } from './usage-api.js';
|
||||||
import { SessionManager } from '../engine/browser-session.js';
|
import { SessionManager } from '../engine/browser-session.js';
|
||||||
import { createNovncRouter, setupNovncWebSocketProxy } from './novnc-proxy.js';
|
import { createNovncRouter, setupNovncWebSocketProxy } from './novnc-proxy.js';
|
||||||
import { setSessionManager } from '../engine/tools/browser.js';
|
import { setSessionManager } from '../engine/tools/browser.js';
|
||||||
@ -104,6 +105,11 @@ import { createNotesApi } from './notes-api.js';
|
|||||||
import { mountGateway, type GatewayMountHandle } from './gateway-mount.js';
|
import { mountGateway, type GatewayMountHandle } from './gateway-mount.js';
|
||||||
import { readGatewayConfig } from '../gateway/config.js';
|
import { readGatewayConfig } from '../gateway/config.js';
|
||||||
import { createAdminGatewayStatusRouter } from './admin-gateway-status-api.js';
|
import { createAdminGatewayStatusRouter } from './admin-gateway-status-api.js';
|
||||||
|
import { createServer as createHttpsServer } from 'https';
|
||||||
|
import { X509Certificate } from 'crypto';
|
||||||
|
import { mergeServerConfig } from '../server/config.js';
|
||||||
|
import { resolveTlsOptions } from '../net/tls-options.js';
|
||||||
|
import { createHttpRedirectServer } from '../net/http-redirect.js';
|
||||||
|
|
||||||
const __filenameServer = fileURLToPath(import.meta.url);
|
const __filenameServer = fileURLToPath(import.meta.url);
|
||||||
const __dirnameServer = dirname(__filenameServer);
|
const __dirnameServer = dirname(__filenameServer);
|
||||||
@ -112,7 +118,7 @@ export interface CoreServerOptions {
|
|||||||
repo: Repository;
|
repo: Repository;
|
||||||
worktreeDir?: string;
|
worktreeDir?: string;
|
||||||
configuredRepos?: string[];
|
configuredRepos?: string[];
|
||||||
generateTitle?: (body: string) => Promise<string>;
|
generateTitle?: (body: string, ownerId?: string) => Promise<string>;
|
||||||
selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
|
selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
|
||||||
configManager?: ConfigManager;
|
configManager?: ConfigManager;
|
||||||
piecesDir?: string;
|
piecesDir?: string;
|
||||||
@ -170,6 +176,14 @@ export function createCoreServer(opts: CoreServerOptions): {
|
|||||||
gatewayMount: GatewayMountHandle | null;
|
gatewayMount: GatewayMountHandle | null;
|
||||||
/** True when an OAuth provider or local auth is active. False = no-auth mode. */
|
/** True when an OAuth provider or local auth is active. False = no-auth mode. */
|
||||||
authActive: boolean;
|
authActive: boolean;
|
||||||
|
/**
|
||||||
|
* Resolved server config snapshot — computed once with the real listen port
|
||||||
|
* so both the cookie-secure decision (inside createCoreServer) and the
|
||||||
|
* listener branch (inside startCoreServer) share the SAME object.
|
||||||
|
* A config hot-reload between the two would otherwise cause tls.enabled to
|
||||||
|
* disagree between the cookie flag and the actual listener type.
|
||||||
|
*/
|
||||||
|
serverConfig: ReturnType<typeof mergeServerConfig>;
|
||||||
} {
|
} {
|
||||||
const { repo, worktreeDir } = opts;
|
const { repo, worktreeDir } = opts;
|
||||||
const app = express();
|
const app = express();
|
||||||
@ -276,6 +290,16 @@ export function createCoreServer(opts: CoreServerOptions): {
|
|||||||
}
|
}
|
||||||
let authenticateUpgrade: import('./auth.js').UpgradeAuthChecker | undefined;
|
let authenticateUpgrade: import('./auth.js').UpgradeAuthChecker | undefined;
|
||||||
|
|
||||||
|
// Resolve server config ONCE here with the real listen port (threaded in via
|
||||||
|
// opts.listenPort by startCoreServer). Both the cookie-secure decision below
|
||||||
|
// and the listener branch in startCoreServer consume this same snapshot so a
|
||||||
|
// config hot-reload between the two calls cannot produce a mismatch between
|
||||||
|
// tls.enabled and the cookie secure flag.
|
||||||
|
const serverCfg = mergeServerConfig(loadConfig().server, {
|
||||||
|
freshInstall: false,
|
||||||
|
httpsPort: opts.listenPort ?? Number(process.env['PORT'] ?? 9876),
|
||||||
|
});
|
||||||
|
|
||||||
if (authActive) {
|
if (authActive) {
|
||||||
// Idempotently seed the shared `local` system admin (id='local', the same
|
// Idempotently seed the shared `local` system admin (id='local', the same
|
||||||
// owner the no-auth path uses) so an existing single-user / no-auth
|
// owner the no-auth path uses) so an existing single-user / no-auth
|
||||||
@ -286,9 +310,21 @@ export function createCoreServer(opts: CoreServerOptions): {
|
|||||||
logger.info(`[auth] seeded local system admin id=local email=${bootstrap.email}`);
|
logger.info(`[auth] seeded local system admin id=local email=${bootstrap.email}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compose effective secureCookie: native TLS termination also requires
|
||||||
|
// the secure flag on session cookies, even when no upstream proxy is
|
||||||
|
// present. IMPORTANT: trust-proxy (line ~191) stays keyed on the
|
||||||
|
// ORIGINAL opts.authConfig.secureCookie — native TLS must NOT enable it.
|
||||||
|
const effectiveSecureCookie = computeEffectiveSecureCookie(
|
||||||
|
!!opts.authConfig?.secureCookie,
|
||||||
|
serverCfg.tls.enabled,
|
||||||
|
);
|
||||||
|
const authConfigForSetup = opts.authConfig
|
||||||
|
? { ...opts.authConfig, secureCookie: effectiveSecureCookie }
|
||||||
|
: opts.authConfig;
|
||||||
|
|
||||||
const auth = setupAuth(
|
const auth = setupAuth(
|
||||||
repo,
|
repo,
|
||||||
opts.authConfig!,
|
authConfigForSetup!,
|
||||||
() => {
|
() => {
|
||||||
const b = resolveBranding(opts.configManager);
|
const b = resolveBranding(opts.configManager);
|
||||||
return { appName: b.appName, loginPageTitle: b.loginPageTitle };
|
return { appName: b.appName, loginPageTitle: b.loginPageTitle };
|
||||||
@ -330,6 +366,7 @@ export function createCoreServer(opts: CoreServerOptions): {
|
|||||||
// per-piece write authz (built-in/global-custom → admin, user-custom → owner)
|
// per-piece write authz (built-in/global-custom → admin, user-custom → owner)
|
||||||
// is enforced inside pieces-api.ts handlers.
|
// is enforced inside pieces-api.ts handlers.
|
||||||
app.use('/api/pieces', requireAuth);
|
app.use('/api/pieces', requireAuth);
|
||||||
|
app.use('/api/usage', requireAuth);
|
||||||
// Scheduled tasks: any authenticated user can create/list (visibility-filtered).
|
// Scheduled tasks: any authenticated user can create/list (visibility-filtered).
|
||||||
// PATCH/DELETE owner-or-admin enforcement lives in the handlers (Task 14).
|
// PATCH/DELETE owner-or-admin enforcement lives in the handlers (Task 14).
|
||||||
app.use('/api/scheduled-tasks', requireAuth);
|
app.use('/api/scheduled-tasks', requireAuth);
|
||||||
@ -893,10 +930,11 @@ export function createCoreServer(opts: CoreServerOptions): {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- Local files API ---
|
// --- Local files API ---
|
||||||
mountLocalFilesApi(app, repo);
|
mountLocalFilesApi(app, repo, { authActive });
|
||||||
|
|
||||||
// --- Subtask activity API ---
|
// --- Subtask activity API ---
|
||||||
app.use('/api/local/tasks', createSubtaskActivityRouter(repo));
|
app.use('/api/local/tasks', createSubtaskActivityRouter(repo));
|
||||||
|
app.use('/api/usage', createUsageRouter(repo, { authActive }));
|
||||||
|
|
||||||
// --- Subtask files API (listing MUST come before wildcard) ---
|
// --- Subtask files API (listing MUST come before wildcard) ---
|
||||||
mountSubtaskFilesApi(app, repo);
|
mountSubtaskFilesApi(app, repo);
|
||||||
@ -1176,7 +1214,19 @@ export function createCoreServer(opts: CoreServerOptions): {
|
|||||||
return isOwner || user.role === 'admin';
|
return isOwner || user.role === 'admin';
|
||||||
};
|
};
|
||||||
|
|
||||||
return { app, browserSessionManager, authenticateUpgrade, authorizeNovncSession, sshConsole, backendStatusRegistry, workerMetrics, gatewayMount, authActive };
|
return { app, browserSessionManager, authenticateUpgrade, authorizeNovncSession, sshConsole, backendStatusRegistry, workerMetrics, gatewayMount, authActive, serverConfig: serverCfg };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cookie `secure` must be set whenever the user-facing scheme is https —
|
||||||
|
* via an upstream TLS proxy (secureCookie) OR native TLS termination. */
|
||||||
|
export function computeEffectiveSecureCookie(secureCookie: boolean, tlsEnabled: boolean): boolean {
|
||||||
|
return secureCookie || tlsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Heuristic: native TLS + the proxy signal (secure_cookie) likely means a
|
||||||
|
* reverse proxy is also terminating TLS → double-TLS misconfiguration. */
|
||||||
|
export function shouldWarnDoubleTls(tlsEnabled: boolean, secureCookie: boolean): boolean {
|
||||||
|
return tlsEnabled && secureCookie;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function finalizeServer(app: express.Application): express.Application {
|
export function finalizeServer(app: express.Application): express.Application {
|
||||||
@ -1231,6 +1281,7 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
|
|||||||
workerMetrics,
|
workerMetrics,
|
||||||
gatewayMount,
|
gatewayMount,
|
||||||
authActive,
|
authActive,
|
||||||
|
serverConfig,
|
||||||
// Forward the actual port to createCoreServer so the admin gateway
|
// Forward the actual port to createCoreServer so the admin gateway
|
||||||
// status endpoint reports the real bind port (not the PORT env
|
// status endpoint reports the real bind port (not the PORT env
|
||||||
// guess). See `listenPort` doc on CoreServerOptions.
|
// guess). See `listenPort` doc on CoreServerOptions.
|
||||||
@ -1255,7 +1306,53 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
|
|||||||
// 127.0.0.1:9876 port mapping instead.
|
// 127.0.0.1:9876 port mapping instead.
|
||||||
const host = process.env['HOST'] ?? '127.0.0.1';
|
const host = process.env['HOST'] ?? '127.0.0.1';
|
||||||
const isLoopbackBind = host === '127.0.0.1' || host === '::1' || host === 'localhost';
|
const isLoopbackBind = host === '127.0.0.1' || host === '::1' || host === 'localhost';
|
||||||
const server = finalApp.listen(port, host, () => {
|
// Use the config snapshot already resolved in createCoreServer (same port,
|
||||||
|
// same loadConfig() call) — no second read so a hot-reload between the two
|
||||||
|
// cannot make the cookie-secure flag disagree with the listener type.
|
||||||
|
const tls = serverConfig.tls;
|
||||||
|
let server: import('http').Server | import('https').Server;
|
||||||
|
if (tls.enabled) {
|
||||||
|
// Augment the self-signed SAN list with the redirect target host and the
|
||||||
|
// (non-wildcard) bind host so that browsers following the HTTP→HTTPS redirect
|
||||||
|
// always land on a hostname that is covered by the certificate. Provided-cert
|
||||||
|
// deployments are unaffected because resolveTlsOptions ignores selfSignedHosts
|
||||||
|
// when cert_file/key_file are set.
|
||||||
|
const extraSan = [
|
||||||
|
tls.redirectHost,
|
||||||
|
host && host !== '0.0.0.0' && host !== '::' ? host : null,
|
||||||
|
].filter((h): h is string => !!h);
|
||||||
|
const tlsForResolve = extraSan.length
|
||||||
|
? { ...tls, selfSignedHosts: [...tls.selfSignedHosts, ...extraSan] }
|
||||||
|
: tls;
|
||||||
|
const resolved = resolveTlsOptions(tlsForResolve); // fatal throw on bad operator cert
|
||||||
|
server = createHttpsServer({ cert: resolved.cert, key: resolved.key, minVersion: resolved.minVersion }, finalApp);
|
||||||
|
server.listen(port, host, () => {
|
||||||
|
const source = tls.certFile ? `provided(${tls.certFile})` : 'self-signed';
|
||||||
|
const fp = new X509Certificate(resolved.cert).fingerprint256;
|
||||||
|
logger.info(`Core server listening on https://${host}:${port} cert=${source} sha256=${fp}`);
|
||||||
|
if (shouldWarnDoubleTls(true, !!opts.authConfig?.secureCookie)) {
|
||||||
|
logger.warn(
|
||||||
|
`[security] server.tls.enabled is ON while auth.secure_cookie is also ON (reverse-proxy signal). ` +
|
||||||
|
`If a TLS-terminating proxy is in front of this app, set server.tls.enabled: false to avoid double TLS.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (tls.httpRedirect) {
|
||||||
|
if (tls.redirectHost == null && (host === '0.0.0.0' || host === '::')) {
|
||||||
|
logger.warn(
|
||||||
|
`[server] HTTP->HTTPS redirect host falls back to the wildcard bind address (${host}); browsers cannot follow it. ` +
|
||||||
|
`Set server.tls.redirect_host to the externally reachable hostname.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const pinnedHost = tls.redirectHost ?? (isLoopbackBind ? 'localhost' : host);
|
||||||
|
const redirector = createHttpRedirectServer({ httpsPort: port, pinnedHost });
|
||||||
|
redirector.on('error', (e) => logger.warn(`[server] HTTP redirect listener error: ${(e as Error).message}`));
|
||||||
|
redirector.listen(tls.httpRedirectPort, host, () =>
|
||||||
|
logger.info(`HTTP->HTTPS redirect listening on http://${host}:${tls.httpRedirectPort}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
server = finalApp.listen(port, host, () => {
|
||||||
logger.info(`Core server listening on ${host}:${port}`);
|
logger.info(`Core server listening on ${host}:${port}`);
|
||||||
if (!isLoopbackBind && !authActive) {
|
if (!isLoopbackBind && !authActive) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@ -1267,6 +1364,7 @@ export function startCoreServer(opts: CoreServerOptions, port: number = 9876): v
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 起動と同時に CAPTCHA Pool の idle GC を回す (task session を 5 分アイドルで GC)
|
// 起動と同時に CAPTCHA Pool の idle GC を回す (task session を 5 分アイドルで GC)
|
||||||
if (browserSessionManager) browserSessionManager.startIdleGc();
|
if (browserSessionManager) browserSessionManager.startIdleGc();
|
||||||
|
|||||||
135
src/bridge/usage-api.test.ts
Normal file
135
src/bridge/usage-api.test.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Usage dashboard API (GET /api/usage/daily) tests.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* - admin sees all users + byUser breakdown; non-admin scoped to own rows
|
||||||
|
* - no-auth (authActive=false) sees everyone (scope 'all')
|
||||||
|
* - day / week / month bucketing collapses model/route correctly
|
||||||
|
* - inclusive range, default range, from>to → 400, range-too-large → 400
|
||||||
|
* - invalid dates fall back to defaults (not 500)
|
||||||
|
*
|
||||||
|
* Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { Repository } from '../db/repository.js';
|
||||||
|
import { createUsageRouter } from './usage-api.js';
|
||||||
|
|
||||||
|
function makeApp(repo: Repository, opts: { authActive: boolean; user?: { id: string; role?: string } }) {
|
||||||
|
const app = express();
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
if (opts.user) (req as unknown as { user: unknown }).user = opts.user;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use('/api/usage', createUsageRouter(repo, { authActive: opts.authActive }));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seed(repo: Repository, rows: Array<{ day: string; userId: string; source: 'gateway' | 'direct'; model?: string; route?: string; tin: number; tout: number; req?: number }>) {
|
||||||
|
for (const r of rows) {
|
||||||
|
repo.incrementLlmUsage({
|
||||||
|
day: r.day, userId: r.userId, source: r.source,
|
||||||
|
model: r.model ?? 'm', route: r.route ?? 'r',
|
||||||
|
tokensIn: r.tin, tokensOut: r.tout, requests: r.req ?? 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET /api/usage/daily', () => {
|
||||||
|
let repo: Repository;
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = new Repository(':memory:');
|
||||||
|
seed(repo, [
|
||||||
|
{ day: '2026-06-10', userId: 'u1', source: 'gateway', tin: 100, tout: 40 },
|
||||||
|
{ day: '2026-06-10', userId: 'u1', source: 'direct', model: 'x', route: 'h', tin: 10, tout: 5 },
|
||||||
|
{ day: '2026-06-11', userId: 'u1', source: 'gateway', tin: 7, tout: 3 },
|
||||||
|
{ day: '2026-06-11', userId: 'u2', source: 'direct', tin: 1000, tout: 500 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-admin sees only their own rows (scope=self, no byUser)', async () => {
|
||||||
|
const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
|
||||||
|
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.scope).toBe('self');
|
||||||
|
expect(res.body.byUser).toBeUndefined();
|
||||||
|
// u1 only: gateway 100+40+7+3=150, direct 10+5=15
|
||||||
|
expect(res.body.totals.gateway).toMatchObject({ tokensIn: 107, tokensOut: 43, requests: 2 });
|
||||||
|
expect(res.body.totals.direct).toMatchObject({ tokensIn: 10, tokensOut: 5, requests: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('admin sees all users with a byUser breakdown', async () => {
|
||||||
|
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
|
||||||
|
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.scope).toBe('all');
|
||||||
|
expect(res.body.totals.direct.tokensIn).toBe(1010); // u1 10 + u2 1000
|
||||||
|
const users = (res.body.byUser as Array<{ userId: string }>).map((u) => u.userId).sort();
|
||||||
|
expect(users).toEqual(['u1', 'u2']);
|
||||||
|
// sorted by total tokens desc → u2 (1500) first
|
||||||
|
expect(res.body.byUser[0].userId).toBe('u2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves byUser display names (real users → name, sentinels verbatim)', async () => {
|
||||||
|
const u = repo.createUser({ email: 'alice@example.com', name: 'Alice', role: 'user', status: 'active' });
|
||||||
|
seed(repo, [
|
||||||
|
{ day: '2026-06-11', userId: u.id, source: 'direct', tin: 5, tout: 5 },
|
||||||
|
{ day: '2026-06-11', userId: 'local', source: 'direct', tin: 1, tout: 1 },
|
||||||
|
]);
|
||||||
|
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
|
||||||
|
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
|
||||||
|
const byId = Object.fromEntries((res.body.byUser as Array<{ userId: string; displayName: string }>).map((r) => [r.userId, r.displayName]));
|
||||||
|
expect(byId[u.id]).toBe('Alice');
|
||||||
|
expect(byId['local']).toBe('local'); // sentinel returned verbatim for UI localization
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no-auth mode (authActive=false) sees everyone', async () => {
|
||||||
|
const app = makeApp(repo, { authActive: false }); // no req.user
|
||||||
|
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.scope).toBe('all');
|
||||||
|
expect(res.body.byUser.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('day granularity yields one bucket per active day', async () => {
|
||||||
|
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
|
||||||
|
const res = await request(app).get('/api/usage/daily?from=2026-06-10&to=2026-06-11&granularity=day');
|
||||||
|
expect(res.body.series.map((b: { bucket: string }) => b.bucket)).toEqual(['2026-06-10', '2026-06-11']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('month granularity collapses days into a YYYY-MM bucket', async () => {
|
||||||
|
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
|
||||||
|
const res = await request(app).get('/api/usage/daily?from=2026-06-01&to=2026-06-30&granularity=month');
|
||||||
|
expect(res.body.series).toHaveLength(1);
|
||||||
|
expect(res.body.series[0].bucket).toBe('2026-06');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('week granularity uses an ISO YYYY-Www bucket', async () => {
|
||||||
|
const app = makeApp(repo, { authActive: true, user: { id: 'admin1', role: 'admin' } });
|
||||||
|
const res = await request(app).get('/api/usage/daily?from=2026-06-08&to=2026-06-14&granularity=week');
|
||||||
|
// 2026-06-10 / -11 fall in ISO week 24 of 2026
|
||||||
|
expect(res.body.series).toHaveLength(1);
|
||||||
|
expect(res.body.series[0].bucket).toBe('2026-W24');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects from > to with 400', async () => {
|
||||||
|
const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
|
||||||
|
const res = await request(app).get('/api/usage/daily?from=2026-06-30&to=2026-06-01');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an absurdly large range with 400', async () => {
|
||||||
|
const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
|
||||||
|
const res = await request(app).get('/api/usage/daily?from=2000-01-01&to=2026-06-30');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to defaults for invalid dates (no 500)', async () => {
|
||||||
|
const app = makeApp(repo, { authActive: true, user: { id: 'u1', role: 'user' } });
|
||||||
|
const res = await request(app).get('/api/usage/daily?from=2026-99-99');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// default window is the last 30 days, ending today
|
||||||
|
expect(res.body.to).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
171
src/bridge/usage-api.ts
Normal file
171
src/bridge/usage-api.ts
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import type { Repository, LlmUsageDailyAgg } from '../db/repository.js';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-user LLM usage dashboard API. Reads the llm_usage_daily ledger
|
||||||
|
* (gateway + direct, recorded at the OpenAICompatClient completion
|
||||||
|
* boundary) and shapes a time series for the Usage tab.
|
||||||
|
*
|
||||||
|
* Visibility: admin (and the no-auth single-user local mode) see every
|
||||||
|
* user's usage; a non-admin authenticated user sees only their own rows.
|
||||||
|
* This is a separate lens from the gateway per-key billing view — the two
|
||||||
|
* are never summed.
|
||||||
|
*
|
||||||
|
* Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
/** True only for a real calendar day in 'YYYY-MM-DD' (rejects 2026-99-99). */
|
||||||
|
function isValidDay(s: unknown): s is string {
|
||||||
|
if (typeof s !== 'string' || !DAY_RE.test(s)) return false;
|
||||||
|
const d = new Date(`${s}T00:00:00.000Z`);
|
||||||
|
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s;
|
||||||
|
}
|
||||||
|
const MAX_RANGE_DAYS = 800; // ~2y guard so a hand-crafted range can't scan unbounded
|
||||||
|
type Granularity = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
interface Counters {
|
||||||
|
tokensIn: number;
|
||||||
|
tokensOut: number;
|
||||||
|
requests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyCounters(): Counters {
|
||||||
|
return { tokensIn: 0, tokensOut: 0, requests: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addInto(target: Counters, row: LlmUsageDailyAgg): void {
|
||||||
|
target.tokensIn += row.tokensIn;
|
||||||
|
target.tokensOut += row.tokensOut;
|
||||||
|
target.requests += row.requests;
|
||||||
|
}
|
||||||
|
|
||||||
|
function utcToday(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** day - n days, as 'YYYY-MM-DD' (UTC). */
|
||||||
|
function shiftDay(day: string, deltaDays: number): string {
|
||||||
|
const d = new Date(`${day}T00:00:00.000Z`);
|
||||||
|
d.setUTCDate(d.getUTCDate() + deltaDays);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inclusive day count between two 'YYYY-MM-DD' (UTC). */
|
||||||
|
function dayDiff(from: string, to: string): number {
|
||||||
|
const a = Date.parse(`${from}T00:00:00.000Z`);
|
||||||
|
const b = Date.parse(`${to}T00:00:00.000Z`);
|
||||||
|
return Math.round((b - a) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO-8601 week key 'YYYY-Www' for a 'YYYY-MM-DD' day (UTC). */
|
||||||
|
function isoWeekKey(day: string): string {
|
||||||
|
const d = new Date(`${day}T00:00:00.000Z`);
|
||||||
|
// ISO week: Thursday of the current week decides the year.
|
||||||
|
const dayNum = (d.getUTCDay() + 6) % 7; // Mon=0 .. Sun=6
|
||||||
|
d.setUTCDate(d.getUTCDate() - dayNum + 3);
|
||||||
|
const firstThursday = new Date(Date.UTC(d.getUTCFullYear(), 0, 4));
|
||||||
|
const firstDayNum = (firstThursday.getUTCDay() + 6) % 7;
|
||||||
|
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3);
|
||||||
|
const week = 1 + Math.round((d.getTime() - firstThursday.getTime()) / (7 * 86_400_000));
|
||||||
|
return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketKey(day: string, granularity: Granularity): string {
|
||||||
|
if (granularity === 'month') return day.slice(0, 7);
|
||||||
|
if (granularity === 'week') return isoWeekKey(day);
|
||||||
|
return day;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-friendly label for a usage owner id. Real users resolve to their
|
||||||
|
* name (or email) so the admin breakdown isn't a wall of opaque ids; the
|
||||||
|
* 'local' / 'system' sentinels are returned verbatim so the UI can localize
|
||||||
|
* them. Falls back to the raw id when no user row exists.
|
||||||
|
*/
|
||||||
|
function resolveDisplayName(repo: Repository, userId: string): string {
|
||||||
|
if (userId === 'local' || userId === 'system') return userId;
|
||||||
|
const u = repo.getUserById(userId);
|
||||||
|
return u?.name || u?.email || userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUsageRouter(repo: Repository, opts: { authActive: boolean }): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// GET /daily?from=YYYY-MM-DD&to=YYYY-MM-DD&granularity=day|week|month
|
||||||
|
router.get('/daily', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const to = isValidDay(req.query['to']) ? req.query['to'] : utcToday();
|
||||||
|
const from = isValidDay(req.query['from']) ? req.query['from'] : shiftDay(to, -29);
|
||||||
|
if (from > to) {
|
||||||
|
res.status(400).json({ error: 'from must be on or before to' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dayDiff(from, to) > MAX_RANGE_DAYS) {
|
||||||
|
res.status(400).json({ error: `range too large (max ${MAX_RANGE_DAYS} days)` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const gq = req.query['granularity'];
|
||||||
|
const granularity: Granularity =
|
||||||
|
gq === 'week' || gq === 'month' ? gq : 'day';
|
||||||
|
|
||||||
|
// Visibility: a non-admin authenticated user is scoped to their own
|
||||||
|
// rows. Admin and the no-auth local mode see everyone.
|
||||||
|
const user = req.user as Express.User | undefined;
|
||||||
|
const isAdmin = !opts.authActive || user?.role === 'admin';
|
||||||
|
const scopeUserId = isAdmin ? undefined : (user?.id ?? 'local');
|
||||||
|
|
||||||
|
const rows = repo.queryLlmUsageDaily({ from, to, userId: scopeUserId });
|
||||||
|
|
||||||
|
// Bucket by (bucketKey, source). Buckets are sparse — only days with
|
||||||
|
// usage appear; the client fills gaps for the chart.
|
||||||
|
const buckets = new Map<string, { gateway: Counters; direct: Counters }>();
|
||||||
|
const totals = { gateway: emptyCounters(), direct: emptyCounters() };
|
||||||
|
const byUser = new Map<string, Counters>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = bucketKey(row.day, granularity);
|
||||||
|
let b = buckets.get(key);
|
||||||
|
if (!b) {
|
||||||
|
b = { gateway: emptyCounters(), direct: emptyCounters() };
|
||||||
|
buckets.set(key, b);
|
||||||
|
}
|
||||||
|
const sourceKey = row.source === 'gateway' ? 'gateway' : 'direct';
|
||||||
|
addInto(b[sourceKey], row);
|
||||||
|
addInto(totals[sourceKey], row);
|
||||||
|
if (isAdmin) {
|
||||||
|
let u = byUser.get(row.userId);
|
||||||
|
if (!u) { u = emptyCounters(); byUser.set(row.userId, u); }
|
||||||
|
addInto(u, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const series = Array.from(buckets.entries())
|
||||||
|
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
||||||
|
.map(([bucket, c]) => ({ bucket, gateway: c.gateway, direct: c.direct }));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
granularity,
|
||||||
|
scope: isAdmin ? 'all' : 'self',
|
||||||
|
series,
|
||||||
|
totals,
|
||||||
|
...(isAdmin
|
||||||
|
? {
|
||||||
|
byUser: Array.from(byUser.entries())
|
||||||
|
.map(([userId, c]) => ({ userId, displayName: resolveDisplayName(repo, userId), ...c }))
|
||||||
|
.sort((a, b) => (b.tokensIn + b.tokensOut) - (a.tokensIn + a.tokensOut)),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`[usage-api] /daily failed: ${String(e)}`);
|
||||||
|
res.status(500).json({ error: 'Failed to load usage' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import { logger } from './logger.js';
|
|||||||
import { normalizeConfig } from './config-normalize.js';
|
import { normalizeConfig } from './config-normalize.js';
|
||||||
import type { McpRuntimeConfig } from './mcp/config.js';
|
import type { McpRuntimeConfig } from './mcp/config.js';
|
||||||
import type { SshRuntimeConfig } from './ssh/config.js';
|
import type { SshRuntimeConfig } from './ssh/config.js';
|
||||||
|
import type { ServerConfig } from './server/config.js';
|
||||||
|
|
||||||
export interface AskConfig {
|
export interface AskConfig {
|
||||||
maxPerJob: number; // default: 2
|
maxPerJob: number; // default: 2
|
||||||
@ -60,6 +61,7 @@ export interface ToolsConfig {
|
|||||||
officePdfMaxSizeMb?: number; // ReadPdf の最大ファイルサイズ (default: 10)
|
officePdfMaxSizeMb?: number; // ReadPdf の最大ファイルサイズ (default: 10)
|
||||||
officePptxMaxSizeMb?: number; // ReadPPTX の最大ファイルサイズ (default: 50)
|
officePptxMaxSizeMb?: number; // ReadPPTX の最大ファイルサイズ (default: 50)
|
||||||
officePptxMaxUncompressedMb?: number; // ReadPPTX の ZIP 展開後サイズ上限 (default: 200)
|
officePptxMaxUncompressedMb?: number; // ReadPPTX の ZIP 展開後サイズ上限 (default: 200)
|
||||||
|
officeMsgMaxSizeMb?: number; // ReadMsg の最大ファイルサイズ (default: 25)
|
||||||
/**
|
/**
|
||||||
* Max request body size (MB) for the POST /api/local/tasks and
|
* Max request body size (MB) for the POST /api/local/tasks and
|
||||||
* /api/local/tasks/:id/comments endpoints (includes base64-encoded
|
* /api/local/tasks/:id/comments endpoints (includes base64-encoded
|
||||||
@ -502,6 +504,7 @@ export interface AppConfig {
|
|||||||
ssh?: Partial<SshRuntimeConfig>;
|
ssh?: Partial<SshRuntimeConfig>;
|
||||||
notes?: NotesConfig;
|
notes?: NotesConfig;
|
||||||
notifications?: NotificationsConfig;
|
notifications?: NotificationsConfig;
|
||||||
|
server?: Partial<ServerConfig>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_REFLECTION: ReflectionConfig = {
|
const DEFAULT_REFLECTION: ReflectionConfig = {
|
||||||
|
|||||||
@ -338,27 +338,38 @@ describe('runReflectionJob integration', () => {
|
|||||||
}),
|
}),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
// Mock fetch so callReflectionLlm returns a valid abstain result
|
// Mock fetch so callReflectionLlm (now streaming via OpenAICompatClient)
|
||||||
const mockFetch = vi.fn().mockResolvedValue({
|
// returns a valid abstain result as an SSE submit_reflection tool_call.
|
||||||
ok: true,
|
const reflectionArgs = JSON.stringify({
|
||||||
json: async () => ({
|
|
||||||
choices: [{
|
|
||||||
message: {
|
|
||||||
tool_calls: [{
|
|
||||||
function: {
|
|
||||||
arguments: JSON.stringify({
|
|
||||||
memory_changes: [],
|
memory_changes: [],
|
||||||
piece_changes: { should_edit: false },
|
piece_changes: { should_edit: false },
|
||||||
reasoning: 'nothing to learn',
|
reasoning: 'nothing to learn',
|
||||||
abstain_reason: 'task completed successfully without issues',
|
abstain_reason: 'task completed successfully without issues',
|
||||||
|
});
|
||||||
|
const sseLines = [
|
||||||
|
`data: ${JSON.stringify({ model: 'reflect-model', choices: [{ delta: { tool_calls: [{ index: 0, id: 'r1', function: { name: 'submit_reflection', arguments: reflectionArgs } }] }, finish_reason: null }] })}\n\n`,
|
||||||
|
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'tool_calls' }] })}\n\n`,
|
||||||
|
`data: ${JSON.stringify({ choices: [], usage: { prompt_tokens: 123, completion_tokens: 45 } })}\n\n`,
|
||||||
|
'data: [DONE]\n\n',
|
||||||
|
];
|
||||||
|
const mockFetch = vi.fn().mockImplementation(async () => {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let i = 0;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: { get: () => null },
|
||||||
|
body: {
|
||||||
|
getReader: () => ({
|
||||||
|
read: async () =>
|
||||||
|
i < sseLines.length
|
||||||
|
? { done: false, value: encoder.encode(sseLines[i++]) }
|
||||||
|
: { done: true, value: undefined },
|
||||||
|
releaseLock: () => {},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}],
|
} as any;
|
||||||
},
|
});
|
||||||
}],
|
|
||||||
usage: { prompt_tokens: 123, completion_tokens: 45 },
|
|
||||||
}),
|
|
||||||
} as any);
|
|
||||||
vi.stubGlobal('fetch', mockFetch);
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
const { runReflectionJob } = await import('../engine/reflection/reflection-runner.js');
|
const { runReflectionJob } = await import('../engine/reflection/reflection-runner.js');
|
||||||
|
|||||||
@ -90,12 +90,44 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
db.exec("ALTER TABLE local_tasks ADD COLUMN options TEXT DEFAULT '{}'");
|
db.exec("ALTER TABLE local_tasks ADD COLUMN options TEXT DEFAULT '{}'");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Title provenance: 'auto' (creation fallback) / 'agent' (derived from
|
||||||
|
// Mission Brief goal) / 'user' (manual edit, never auto-overwritten).
|
||||||
|
addColumnIfMissing(db, 'local_tasks', 'title_source', () => {
|
||||||
|
db.exec("ALTER TABLE local_tasks ADD COLUMN title_source TEXT NOT NULL DEFAULT 'auto'");
|
||||||
|
});
|
||||||
|
|
||||||
migrateMcpTables(db);
|
migrateMcpTables(db);
|
||||||
migrateSshTables(db);
|
migrateSshTables(db);
|
||||||
migrateNotesTables(db);
|
migrateNotesTables(db);
|
||||||
migrateDashboardWidgets(db);
|
migrateDashboardWidgets(db);
|
||||||
migrateGatewayVirtualKeys(db);
|
migrateGatewayVirtualKeys(db);
|
||||||
migratePushNotificationsTables(db);
|
migratePushNotificationsTables(db);
|
||||||
|
migrateLlmUsageDaily(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-user daily LLM usage aggregation (gateway + direct). Idempotent.
|
||||||
|
* Mirrors schema.sql + Repository.initSchema (dual-path rule:
|
||||||
|
* project_db_migration_dual_path). Additive table, no mixed-version risk.
|
||||||
|
* Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md.
|
||||||
|
*/
|
||||||
|
function migrateLlmUsageDaily(db: Database.Database): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS llm_usage_daily (
|
||||||
|
day TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
route TEXT NOT NULL,
|
||||||
|
tokens_in INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tokens_out INTEGER NOT NULL DEFAULT 0,
|
||||||
|
requests INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (day, user_id, source, model, route)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_usage_daily_user_day
|
||||||
|
ON llm_usage_daily (user_id, day);
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
105
src/db/repository.llm-usage.test.ts
Normal file
105
src/db/repository.llm-usage.test.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Per-user daily LLM usage ledger (llm_usage_daily) repository tests.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* - incrementLlmUsage UPSERTs on first call, accumulates on second
|
||||||
|
* - requests defaults to +1; a usage-less call still bumps requests
|
||||||
|
* - negative deltas clamp to zero
|
||||||
|
* - distinct (model) and (route) produce distinct rows on the same day
|
||||||
|
* - 'system' / 'local' sentinels aggregate as single rows (NULL trap avoided)
|
||||||
|
* - queryLlmUsageDaily collapses model/route, groups by (day, user, source)
|
||||||
|
* - day filter is an inclusive range; userId filter scopes a single user
|
||||||
|
* - UTC day boundary splits into separate buckets
|
||||||
|
*
|
||||||
|
* Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
import { Repository } from './repository.js';
|
||||||
|
|
||||||
|
function makeRepo(): Repository {
|
||||||
|
return new Repository(':memory:');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('llm_usage_daily repository', () => {
|
||||||
|
let repo: Repository;
|
||||||
|
beforeEach(() => {
|
||||||
|
repo = makeRepo();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UPSERTs first call and accumulates on the same grain', () => {
|
||||||
|
const grain = { day: '2026-06-11', userId: 'u1', source: 'direct' as const, model: 'm', route: 'r' };
|
||||||
|
repo.incrementLlmUsage({ ...grain, tokensIn: 100, tokensOut: 40 });
|
||||||
|
repo.incrementLlmUsage({ ...grain, tokensIn: 10, tokensOut: 5 });
|
||||||
|
const rows = repo.queryLlmUsageDaily({ from: '2026-06-11', to: '2026-06-11' });
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]).toMatchObject({
|
||||||
|
day: '2026-06-11', userId: 'u1', source: 'direct',
|
||||||
|
tokensIn: 110, tokensOut: 45, requests: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a usage-less call still bumps requests (0-token request != failure)', () => {
|
||||||
|
repo.incrementLlmUsage({ day: '2026-06-11', userId: 'u1', source: 'gateway', model: 'm', route: 'r' });
|
||||||
|
const rows = repo.queryLlmUsageDaily({ from: '2026-06-11', to: '2026-06-11' });
|
||||||
|
expect(rows[0]).toMatchObject({ tokensIn: 0, tokensOut: 0, requests: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps negative deltas to zero', () => {
|
||||||
|
repo.incrementLlmUsage({ day: '2026-06-11', userId: 'u1', source: 'direct', model: 'm', route: 'r', tokensIn: -5, tokensOut: -9, requests: -3 });
|
||||||
|
const rows = repo.queryLlmUsageDaily({ from: '2026-06-11', to: '2026-06-11' });
|
||||||
|
expect(rows[0]).toMatchObject({ tokensIn: 0, tokensOut: 0, requests: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('distinct model and route are separate rows but collapse in the query', () => {
|
||||||
|
const base = { day: '2026-06-11', userId: 'u1', source: 'direct' as const, tokensIn: 10, tokensOut: 5 };
|
||||||
|
repo.incrementLlmUsage({ ...base, model: 'big', route: 'host-a' });
|
||||||
|
repo.incrementLlmUsage({ ...base, model: 'small', route: 'host-a' });
|
||||||
|
repo.incrementLlmUsage({ ...base, model: 'big', route: 'host-b' });
|
||||||
|
// 3 distinct (model,route) rows underneath, collapsed to one (day,user,source).
|
||||||
|
const rows = repo.queryLlmUsageDaily({ from: '2026-06-11', to: '2026-06-11' });
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]).toMatchObject({ tokensIn: 30, tokensOut: 15, requests: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'system' and 'local' sentinels each aggregate as a single row", () => {
|
||||||
|
const g = { day: '2026-06-11', source: 'direct' as const, model: 'm', route: 'r', tokensIn: 1, tokensOut: 1 };
|
||||||
|
repo.incrementLlmUsage({ ...g, userId: 'system' });
|
||||||
|
repo.incrementLlmUsage({ ...g, userId: 'system' });
|
||||||
|
repo.incrementLlmUsage({ ...g, userId: 'local' });
|
||||||
|
const rows = repo.queryLlmUsageDaily({ from: '2026-06-11', to: '2026-06-11' });
|
||||||
|
const byUser = Object.fromEntries(rows.map((r) => [r.userId, r.requests]));
|
||||||
|
expect(byUser).toEqual({ system: 2, local: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gateway and direct are distinct rows for the same user/day', () => {
|
||||||
|
repo.incrementLlmUsage({ day: '2026-06-11', userId: 'u1', source: 'gateway', model: 'm', route: 'r', tokensIn: 7, tokensOut: 3 });
|
||||||
|
repo.incrementLlmUsage({ day: '2026-06-11', userId: 'u1', source: 'direct', model: 'm', route: 'r', tokensIn: 2, tokensOut: 1 });
|
||||||
|
const rows = repo.queryLlmUsageDaily({ from: '2026-06-11', to: '2026-06-11' });
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows.map((r) => r.source).sort()).toEqual(['direct', 'gateway']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queryLlmUsageDaily honours an inclusive day range', () => {
|
||||||
|
for (const day of ['2026-06-09', '2026-06-10', '2026-06-11', '2026-06-12']) {
|
||||||
|
repo.incrementLlmUsage({ day, userId: 'u1', source: 'direct', model: 'm', route: 'r', tokensIn: 1, tokensOut: 0 });
|
||||||
|
}
|
||||||
|
const rows = repo.queryLlmUsageDaily({ from: '2026-06-10', to: '2026-06-11' });
|
||||||
|
expect(rows.map((r) => r.day)).toEqual(['2026-06-10', '2026-06-11']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('userId filter scopes the query to one user', () => {
|
||||||
|
repo.incrementLlmUsage({ day: '2026-06-11', userId: 'u1', source: 'direct', model: 'm', route: 'r', tokensIn: 1, tokensOut: 0 });
|
||||||
|
repo.incrementLlmUsage({ day: '2026-06-11', userId: 'u2', source: 'direct', model: 'm', route: 'r', tokensIn: 9, tokensOut: 0 });
|
||||||
|
const mine = repo.queryLlmUsageDaily({ from: '2026-06-11', to: '2026-06-11', userId: 'u1' });
|
||||||
|
expect(mine).toHaveLength(1);
|
||||||
|
expect(mine[0]).toMatchObject({ userId: 'u1', tokensIn: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives the UTC day from `at` when day is omitted; boundary splits buckets', () => {
|
||||||
|
const grain = { userId: 'u1', source: 'direct' as const, model: 'm', route: 'r', tokensIn: 1, tokensOut: 0 };
|
||||||
|
repo.incrementLlmUsage({ ...grain, at: '2026-06-11T23:59:59.000Z' });
|
||||||
|
repo.incrementLlmUsage({ ...grain, at: '2026-06-12T00:00:01.000Z' });
|
||||||
|
const rows = repo.queryLlmUsageDaily({ from: '2026-06-01', to: '2026-06-30' });
|
||||||
|
expect(rows.map((r) => r.day)).toEqual(['2026-06-11', '2026-06-12']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1653,3 +1653,74 @@ describe('Repository browser notifications V2', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Repository title derivation from Mission Brief goal', () => {
|
||||||
|
let tempDir = '';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (tempDir) {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
tempDir = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeRepo(): Repository {
|
||||||
|
tempDir = mkdtempSync(join(tmpdir(), 'maestro-title-'));
|
||||||
|
return new Repository(join(tempDir, 'orchestrator.db'));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('derives the title from the goal when title is auto, and marks it agent', async () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
try {
|
||||||
|
const task = await repo.createLocalTask({ title: '仮タイトル', titleSource: 'auto', body: 'b' });
|
||||||
|
repo.makeMissionBriefIO(task.id).update({ goal: '議事録を作成する\n背景...' });
|
||||||
|
const after = await repo.getLocalTask(task.id);
|
||||||
|
expect(after?.title).toBe('議事録を作成する');
|
||||||
|
expect(after?.titleSource).toBe('agent');
|
||||||
|
} finally {
|
||||||
|
repo.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-derives on a later goal update while still agent-owned', async () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
try {
|
||||||
|
const task = await repo.createLocalTask({ title: 'x', titleSource: 'auto', body: 'b' });
|
||||||
|
const io = repo.makeMissionBriefIO(task.id);
|
||||||
|
io.update({ goal: '最初の目標' });
|
||||||
|
io.update({ goal: '更新された目標' });
|
||||||
|
const after = await repo.getLocalTask(task.id);
|
||||||
|
expect(after?.title).toBe('更新された目標');
|
||||||
|
expect(after?.titleSource).toBe('agent');
|
||||||
|
} finally {
|
||||||
|
repo.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never overwrites a user-edited title', async () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
try {
|
||||||
|
const task = await repo.createLocalTask({ title: 'x', titleSource: 'auto', body: 'b' });
|
||||||
|
await repo.updateLocalTask(task.id, { title: '手動タイトル', titleSource: 'user' });
|
||||||
|
repo.makeMissionBriefIO(task.id).update({ goal: 'エージェントの目標' });
|
||||||
|
const after = await repo.getLocalTask(task.id);
|
||||||
|
expect(after?.title).toBe('手動タイトル');
|
||||||
|
expect(after?.titleSource).toBe('user');
|
||||||
|
} finally {
|
||||||
|
repo.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not touch the title when the patch has no goal', async () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
try {
|
||||||
|
const task = await repo.createLocalTask({ title: '仮', titleSource: 'auto', body: 'b' });
|
||||||
|
repo.makeMissionBriefIO(task.id).update({ done: '- step 1' });
|
||||||
|
const after = await repo.getLocalTask(task.id);
|
||||||
|
expect(after?.title).toBe('仮');
|
||||||
|
expect(after?.titleSource).toBe('auto');
|
||||||
|
} finally {
|
||||||
|
repo.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { randomUUID, scryptSync, randomBytes, timingSafeEqual } from 'crypto';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import { buildVisibilityWhere } from '../bridge/visibility.js';
|
import { buildVisibilityWhere } from '../bridge/visibility.js';
|
||||||
|
import { buildTitleFromGoal } from '../title-generation.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
@ -146,9 +147,13 @@ export interface SubtaskInfo {
|
|||||||
childCompleted?: number;
|
childCompleted?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TitleSource = 'auto' | 'agent' | 'user';
|
||||||
|
|
||||||
export interface LocalTask {
|
export interface LocalTask {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
/** Provenance of `title`. 'user' is never auto-overwritten by the agent. */
|
||||||
|
titleSource: TitleSource;
|
||||||
body: string;
|
body: string;
|
||||||
pieceName: string;
|
pieceName: string;
|
||||||
profile: 'auto' | 'fast' | 'quality' | string;
|
profile: 'auto' | 'fast' | 'quality' | string;
|
||||||
@ -335,6 +340,33 @@ function rowToGatewayKeyUsage(row: GatewayKeyUsageRow): GatewayKeyUsage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-call delta for the per-user daily LLM usage ledger. */
|
||||||
|
export interface LlmUsageIncrement {
|
||||||
|
/** UTC day bucket 'YYYY-MM-DD'. Defaults to today (UTC) when omitted. */
|
||||||
|
day?: string;
|
||||||
|
/** Owner id, or 'local' (no-auth) / 'system' (ownerless) sentinel. */
|
||||||
|
userId: string;
|
||||||
|
source: 'gateway' | 'direct';
|
||||||
|
/** Real model name (chunk.model), routing-key fallback, or 'unknown'. */
|
||||||
|
model: string;
|
||||||
|
/** Backend server name (gateway backendId / direct host), or 'unknown'. */
|
||||||
|
route: string;
|
||||||
|
tokensIn?: number;
|
||||||
|
tokensOut?: number;
|
||||||
|
requests?: number;
|
||||||
|
at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Daily-grouped aggregate row (model/route collapsed) for the usage API. */
|
||||||
|
export interface LlmUsageDailyAgg {
|
||||||
|
day: string;
|
||||||
|
userId: string;
|
||||||
|
source: string;
|
||||||
|
tokensIn: number;
|
||||||
|
tokensOut: number;
|
||||||
|
requests: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Coerce an optional limit (tokens_budget / rate_limit_rpm) to either
|
* Coerce an optional limit (tokens_budget / rate_limit_rpm) to either
|
||||||
* a positive integer or null. Anything else (undefined, null, 0,
|
* a positive integer or null. Anything else (undefined, null, 0,
|
||||||
@ -432,6 +464,8 @@ export interface UpsertWorkerNodeParams {
|
|||||||
|
|
||||||
export interface CreateLocalTaskParams {
|
export interface CreateLocalTaskParams {
|
||||||
title: string;
|
title: string;
|
||||||
|
/** Defaults to 'auto'. Pass 'user' when the caller supplied an explicit title. */
|
||||||
|
titleSource?: TitleSource;
|
||||||
body: string;
|
body: string;
|
||||||
pieceName?: string;
|
pieceName?: string;
|
||||||
profile?: 'auto' | 'fast' | 'quality';
|
profile?: 'auto' | 'fast' | 'quality';
|
||||||
@ -650,6 +684,7 @@ interface JobRow {
|
|||||||
interface LocalTaskRow {
|
interface LocalTaskRow {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
title_source: string | null;
|
||||||
body: string;
|
body: string;
|
||||||
piece_name: string;
|
piece_name: string;
|
||||||
profile: string;
|
profile: string;
|
||||||
@ -802,6 +837,7 @@ function rowToLocalTask(row: LocalTaskRow): LocalTask {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
|
titleSource: (row.title_source as TitleSource | null) ?? 'auto',
|
||||||
body: row.body,
|
body: row.body,
|
||||||
pieceName: row.piece_name,
|
pieceName: row.piece_name,
|
||||||
profile: row.profile,
|
profile: row.profile,
|
||||||
@ -1133,6 +1169,26 @@ export class Repository {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_gateway_usage_key
|
CREATE INDEX IF NOT EXISTS idx_gateway_usage_key
|
||||||
ON gateway_key_usage (key_id);
|
ON gateway_key_usage (key_id);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Per-user daily LLM usage (gateway + direct). Mirrors schema.sql +
|
||||||
|
// migrate.ts (dual-path rule). Separate lens from gateway_key_usage.
|
||||||
|
// Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS llm_usage_daily (
|
||||||
|
day TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
route TEXT NOT NULL,
|
||||||
|
tokens_in INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tokens_out INTEGER NOT NULL DEFAULT 0,
|
||||||
|
requests INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (day, user_id, source, model, route)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_usage_daily_user_day
|
||||||
|
ON llm_usage_daily (user_id, day);
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureColumn(tableName: string, columnName: string, definition: string): void {
|
private ensureColumn(tableName: string, columnName: string, definition: string): void {
|
||||||
@ -1247,11 +1303,12 @@ export class Repository {
|
|||||||
async createLocalTask(params: CreateLocalTaskParams): Promise<LocalTask> {
|
async createLocalTask(params: CreateLocalTaskParams): Promise<LocalTask> {
|
||||||
const result = this.db
|
const result = this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO local_tasks (title, body, piece_name, profile, output_format, ask_policy, priority, workspace_path, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, options)
|
`INSERT INTO local_tasks (title, title_source, body, piece_name, profile, output_format, ask_policy, priority, workspace_path, owner_id, visibility, visibility_scope_org_id, browser_session_profile_id, options)
|
||||||
VALUES (@title, @body, @pieceName, @profile, @outputFormat, @askPolicy, @priority, @workspacePath, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @options)`
|
VALUES (@title, @titleSource, @body, @pieceName, @profile, @outputFormat, @askPolicy, @priority, @workspacePath, @ownerId, @visibility, @visibilityScopeOrgId, @browserSessionProfileId, @options)`
|
||||||
)
|
)
|
||||||
.run({
|
.run({
|
||||||
title: params.title,
|
title: params.title,
|
||||||
|
titleSource: params.titleSource ?? 'auto',
|
||||||
body: params.body,
|
body: params.body,
|
||||||
pieceName: params.pieceName ?? 'chat',
|
pieceName: params.pieceName ?? 'chat',
|
||||||
profile: params.profile ?? 'auto',
|
profile: params.profile ?? 'auto',
|
||||||
@ -1365,9 +1422,10 @@ export class Repository {
|
|||||||
* from sync paths (e.g. buildSystemPrompt). better-sqlite3 is sync
|
* from sync paths (e.g. buildSystemPrompt). better-sqlite3 is sync
|
||||||
* underneath anyway. */
|
* underneath anyway. */
|
||||||
updateMissionBriefSync(taskId: number, patch: Partial<MissionBrief>): MissionBrief | null {
|
updateMissionBriefSync(taskId: number, patch: Partial<MissionBrief>): MissionBrief | null {
|
||||||
const existing = parseMissionBrief(
|
const row = this.db
|
||||||
(this.db.prepare(`SELECT mission_brief FROM local_tasks WHERE id = ?`).get(taskId) as { mission_brief: string | null } | undefined)?.mission_brief ?? null,
|
.prepare(`SELECT mission_brief, title_source FROM local_tasks WHERE id = ?`)
|
||||||
);
|
.get(taskId) as { mission_brief: string | null; title_source: string | null } | undefined;
|
||||||
|
const existing = parseMissionBrief(row?.mission_brief ?? null);
|
||||||
const next: MissionBrief = {
|
const next: MissionBrief = {
|
||||||
goal: patch.goal !== undefined ? patch.goal : existing?.goal ?? '',
|
goal: patch.goal !== undefined ? patch.goal : existing?.goal ?? '',
|
||||||
done: patch.done !== undefined ? patch.done : existing?.done ?? '',
|
done: patch.done !== undefined ? patch.done : existing?.done ?? '',
|
||||||
@ -1376,9 +1434,29 @@ export class Repository {
|
|||||||
};
|
};
|
||||||
const allEmpty = !next.goal && !next.done && !next.open && !next.clarifications;
|
const allEmpty = !next.goal && !next.done && !next.open && !next.clarifications;
|
||||||
const stored = allEmpty ? null : JSON.stringify(next);
|
const stored = allEmpty ? null : JSON.stringify(next);
|
||||||
|
|
||||||
|
// Derive the task title from the agent's goal (no LLM call). Only when the
|
||||||
|
// goal value actually changed (agents re-send an unchanged brief across
|
||||||
|
// iterations — re-deriving every time would churn updated_at and flicker
|
||||||
|
// the title) and the user hasn't taken manual control (a user edit pins
|
||||||
|
// title_source='user' and is never overwritten).
|
||||||
|
const goalChanged = patch.goal !== undefined && patch.goal !== (existing?.goal ?? '');
|
||||||
|
const derivedTitle = (goalChanged && (row?.title_source ?? 'auto') !== 'user')
|
||||||
|
? buildTitleFromGoal(next.goal)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Atomic: persist the brief and the derived title as one unit so a crash
|
||||||
|
// between them can't leave the title out of sync with the goal.
|
||||||
|
this.db.transaction(() => {
|
||||||
this.db.prepare(
|
this.db.prepare(
|
||||||
`UPDATE local_tasks SET mission_brief = ?, updated_at = datetime('now') WHERE id = ?`
|
`UPDATE local_tasks SET mission_brief = ?, updated_at = datetime('now') WHERE id = ?`
|
||||||
).run(stored, taskId);
|
).run(stored, taskId);
|
||||||
|
if (derivedTitle) {
|
||||||
|
this.db.prepare(
|
||||||
|
`UPDATE local_tasks SET title = ?, title_source = 'agent' WHERE id = ?`
|
||||||
|
).run(derivedTitle, taskId);
|
||||||
|
}
|
||||||
|
})();
|
||||||
return allEmpty ? null : next;
|
return allEmpty ? null : next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1636,6 +1714,7 @@ export class Repository {
|
|||||||
const params: Record<string, unknown> = { taskId };
|
const params: Record<string, unknown> = { taskId };
|
||||||
const fieldMap: Record<string, string> = {
|
const fieldMap: Record<string, string> = {
|
||||||
title: 'title',
|
title: 'title',
|
||||||
|
titleSource: 'title_source',
|
||||||
body: 'body',
|
body: 'body',
|
||||||
pieceName: 'piece_name',
|
pieceName: 'piece_name',
|
||||||
profile: 'profile',
|
profile: 'profile',
|
||||||
@ -3490,6 +3569,83 @@ export class Repository {
|
|||||||
return rows.map(rowToGatewayKeyUsage);
|
return rows.map(rowToGatewayKeyUsage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Per-user daily LLM usage (gateway + direct) ──────────────────────
|
||||||
|
//
|
||||||
|
// Recorded at the OpenAICompatClient completion boundary for every
|
||||||
|
// successful chat completion. UPSERT on the (day, user_id, source,
|
||||||
|
// model, route) grain. Separate lens from gateway_key_usage — never
|
||||||
|
// summed across the two tables. Spec:
|
||||||
|
// docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UPSERT: bump per-(day, user, source, model, route) counters. Deltas
|
||||||
|
* are clamped at zero. `day` defaults to the UTC day of `at` (or now).
|
||||||
|
* Called once per successful stream completion; `usage`-less completions
|
||||||
|
* still bump `requests` (tokens 0) so a 0-token request is distinct from
|
||||||
|
* a failed/aborted one (which is never recorded).
|
||||||
|
*/
|
||||||
|
incrementLlmUsage(params: LlmUsageIncrement): void {
|
||||||
|
const tIn = Math.max(0, Math.floor(params.tokensIn ?? 0));
|
||||||
|
const tOut = Math.max(0, Math.floor(params.tokensOut ?? 0));
|
||||||
|
const reqs = Math.max(0, Math.floor(params.requests ?? 1));
|
||||||
|
const ts = params.at ?? new Date().toISOString();
|
||||||
|
const day = params.day ?? ts.slice(0, 10);
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO llm_usage_daily
|
||||||
|
(day, user_id, source, model, route, tokens_in, tokens_out, requests, last_updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT (day, user_id, source, model, route) DO UPDATE SET
|
||||||
|
tokens_in = tokens_in + excluded.tokens_in,
|
||||||
|
tokens_out = tokens_out + excluded.tokens_out,
|
||||||
|
requests = requests + excluded.requests,
|
||||||
|
last_updated_at = excluded.last_updated_at`,
|
||||||
|
)
|
||||||
|
.run(day, params.userId, params.source, params.model, params.route, tIn, tOut, reqs, ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily time series for the usage dashboard, grouped by (day, user_id,
|
||||||
|
* source) with model/route collapsed. `userId` filter scopes a non-admin
|
||||||
|
* to their own rows; omit it for the admin all-users view (callers can
|
||||||
|
* collapse user_id afterwards). Inclusive `from`/`to` are 'YYYY-MM-DD'.
|
||||||
|
*/
|
||||||
|
queryLlmUsageDaily(opts: { from: string; to: string; userId?: string }): LlmUsageDailyAgg[] {
|
||||||
|
const where = ['day >= ?', 'day <= ?'];
|
||||||
|
const args: unknown[] = [opts.from, opts.to];
|
||||||
|
if (opts.userId !== undefined) {
|
||||||
|
where.push('user_id = ?');
|
||||||
|
args.push(opts.userId);
|
||||||
|
}
|
||||||
|
const rows = this.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT day, user_id, source,
|
||||||
|
SUM(tokens_in) AS tokens_in,
|
||||||
|
SUM(tokens_out) AS tokens_out,
|
||||||
|
SUM(requests) AS requests
|
||||||
|
FROM llm_usage_daily
|
||||||
|
WHERE ${where.join(' AND ')}
|
||||||
|
GROUP BY day, user_id, source
|
||||||
|
ORDER BY day ASC`,
|
||||||
|
)
|
||||||
|
.all(...args) as Array<{
|
||||||
|
day: string;
|
||||||
|
user_id: string;
|
||||||
|
source: string;
|
||||||
|
tokens_in: number;
|
||||||
|
tokens_out: number;
|
||||||
|
requests: number;
|
||||||
|
}>;
|
||||||
|
return rows.map((r) => ({
|
||||||
|
day: r.day,
|
||||||
|
userId: r.user_id,
|
||||||
|
source: r.source,
|
||||||
|
tokensIn: r.tokens_in,
|
||||||
|
tokensOut: r.tokens_out,
|
||||||
|
requests: r.requests,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/** Return the underlying Database instance (needed by migrate.ts and session store) */
|
/** Return the underlying Database instance (needed by migrate.ts and session store) */
|
||||||
getDb(): Database.Database {
|
getDb(): Database.Database {
|
||||||
return this.db;
|
return this.db;
|
||||||
|
|||||||
@ -65,7 +65,11 @@ CREATE TABLE IF NOT EXISTS local_tasks (
|
|||||||
mission_brief TEXT,
|
mission_brief TEXT,
|
||||||
-- Per-task options (JSON blob). Controls runtime behaviour toggles such as
|
-- Per-task options (JSON blob). Controls runtime behaviour toggles such as
|
||||||
-- { mcpDisabled: true, skillsDisabled: true }. Default '{}' = all enabled.
|
-- { mcpDisabled: true, skillsDisabled: true }. Default '{}' = all enabled.
|
||||||
options TEXT DEFAULT '{}'
|
options TEXT DEFAULT '{}',
|
||||||
|
-- Provenance of `title`: 'auto' = cheap fallback set at creation,
|
||||||
|
-- 'agent' = derived from the Mission Brief goal during the run,
|
||||||
|
-- 'user' = manually edited (never overwritten by agent/regeneration).
|
||||||
|
title_source TEXT NOT NULL DEFAULT 'auto'
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_local_tasks_updated_at ON local_tasks (updated_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_local_tasks_updated_at ON local_tasks (updated_at DESC);
|
||||||
@ -586,6 +590,29 @@ CREATE TABLE IF NOT EXISTS gateway_key_usage (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_gateway_usage_key
|
CREATE INDEX IF NOT EXISTS idx_gateway_usage_key
|
||||||
ON gateway_key_usage (key_id);
|
ON gateway_key_usage (key_id);
|
||||||
|
|
||||||
|
-- ── LLM usage: per-user daily aggregation (gateway + direct) ────────────
|
||||||
|
-- Daily UPSERT buckets recorded at the OpenAICompatClient completion
|
||||||
|
-- boundary, covering BOTH gateway-routed and direct LLM calls. This is a
|
||||||
|
-- separate lens from gateway_key_usage (which is per-virtual-key / billing).
|
||||||
|
-- grain = (day, user_id, source, model, route); user_id is NOT NULL with a
|
||||||
|
-- 'system' / 'local' sentinel so ON CONFLICT keys never hit the SQLite
|
||||||
|
-- NULL != NULL trap. Spec:
|
||||||
|
-- docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
|
||||||
|
CREATE TABLE IF NOT EXISTS llm_usage_daily (
|
||||||
|
day TEXT NOT NULL, -- 'YYYY-MM-DD' (UTC)
|
||||||
|
user_id TEXT NOT NULL, -- owner id / 'local' / 'system'
|
||||||
|
source TEXT NOT NULL, -- 'gateway' | 'direct'
|
||||||
|
model TEXT NOT NULL, -- real model name (chunk.model), routing key fallback
|
||||||
|
route TEXT NOT NULL, -- backend server name (gateway backendId / direct host)
|
||||||
|
tokens_in INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tokens_out INTEGER NOT NULL DEFAULT 0,
|
||||||
|
requests INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (day, user_id, source, model, route)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_usage_daily_user_day
|
||||||
|
ON llm_usage_daily (user_id, day);
|
||||||
|
|
||||||
-- ── Browser Notifications V2: Web Push subscriptions + per-user prefs ───
|
-- ── Browser Notifications V2: Web Push subscriptions + per-user prefs ───
|
||||||
-- Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md
|
-- Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md
|
||||||
-- endpoint is globally UNIQUE so logging into a different user in the same
|
-- endpoint is globally UNIQUE so logging into a different user in the same
|
||||||
|
|||||||
@ -1963,7 +1963,7 @@ export async function executeMovement(
|
|||||||
{ role: 'user', content: taskInstruction },
|
{ role: 'user', content: taskInstruction },
|
||||||
];
|
];
|
||||||
const runIsolatedLlm = (isolatedMessages: Message[]): Promise<string> =>
|
const runIsolatedLlm = (isolatedMessages: Message[]): Promise<string> =>
|
||||||
runIsolatedLlmHelper(client, isolatedMessages, cancelSignal);
|
runIsolatedLlmHelper(client, isolatedMessages, cancelSignal, { userId: ctx.userId });
|
||||||
|
|
||||||
// Traceability T-1: ensure eventLogger is non-undefined for the
|
// Traceability T-1: ensure eventLogger is non-undefined for the
|
||||||
// duration of the movement. Production callers (piece-runner) always
|
// duration of the movement. Production callers (piece-runner) always
|
||||||
@ -2166,6 +2166,7 @@ export async function executeMovement(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
`movement=${movement.name} `,
|
`movement=${movement.name} `,
|
||||||
|
{ userId: ctx.userId },
|
||||||
);
|
);
|
||||||
const llmDurationMs = Date.now() - llmStartedAt;
|
const llmDurationMs = Date.now() - llmStartedAt;
|
||||||
let { accumulatedText } = consumed;
|
let { accumulatedText } = consumed;
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type {
|
|||||||
ToolCall,
|
ToolCall,
|
||||||
OpenAICompatClient,
|
OpenAICompatClient,
|
||||||
LLMEvent,
|
LLMEvent,
|
||||||
|
LlmCallContext,
|
||||||
} from '../llm/openai-compat.js';
|
} from '../llm/openai-compat.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
import { stripThinkingTokens } from './strip-thinking.js';
|
import { stripThinkingTokens } from './strip-thinking.js';
|
||||||
@ -22,9 +23,10 @@ export async function runIsolatedLlm(
|
|||||||
client: OpenAICompatClient,
|
client: OpenAICompatClient,
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
cancelSignal?: AbortSignal,
|
cancelSignal?: AbortSignal,
|
||||||
|
context?: LlmCallContext,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
let output = '';
|
let output = '';
|
||||||
for await (const event of client.chat(messages, undefined, cancelSignal)) {
|
for await (const event of client.chat(messages, undefined, cancelSignal, context)) {
|
||||||
if (event.type === 'text') {
|
if (event.type === 'text') {
|
||||||
output += event.text;
|
output += event.text;
|
||||||
continue;
|
continue;
|
||||||
@ -107,8 +109,9 @@ export async function consumeLlmStream(
|
|||||||
idleTimeoutMs: number,
|
idleTimeoutMs: number,
|
||||||
callbacks: ConsumeStreamCallbacks = {},
|
callbacks: ConsumeStreamCallbacks = {},
|
||||||
contextLabel: string = '',
|
contextLabel: string = '',
|
||||||
|
context?: LlmCallContext,
|
||||||
): Promise<ConsumedLLMResponse> {
|
): Promise<ConsumedLLMResponse> {
|
||||||
const stream = client.chat(messages, tools, cancelSignal);
|
const stream = client.chat(messages, tools, cancelSignal, context);
|
||||||
const accumulator: ConsumedLLMResponse = {
|
const accumulator: ConsumedLLMResponse = {
|
||||||
accumulatedText: '',
|
accumulatedText: '',
|
||||||
pendingToolCalls: [],
|
pendingToolCalls: [],
|
||||||
|
|||||||
@ -79,6 +79,7 @@ export async function classifyPiece(
|
|||||||
pieces: PieceDescription[],
|
pieces: PieceDescription[],
|
||||||
fileNames: string[],
|
fileNames: string[],
|
||||||
timeoutMs: number = 8000,
|
timeoutMs: number = 8000,
|
||||||
|
userId?: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const prompt = buildClassificationPrompt(taskText, pieces, fileNames);
|
const prompt = buildClassificationPrompt(taskText, pieces, fileNames);
|
||||||
logger.debug(`[piece-classifier] candidates=[${pieces.map(p => p.name).join(', ')}] textLen=${taskText.length}`);
|
logger.debug(`[piece-classifier] candidates=[${pieces.map(p => p.name).join(', ')}] textLen=${taskText.length}`);
|
||||||
@ -87,7 +88,7 @@ export async function classifyPiece(
|
|||||||
const llmCall = async (): Promise<string | null> => {
|
const llmCall = async (): Promise<string | null> => {
|
||||||
let result = '';
|
let result = '';
|
||||||
try {
|
try {
|
||||||
for await (const event of client.chat(messages)) {
|
for await (const event of client.chat(messages, undefined, undefined, { userId })) {
|
||||||
if (event.type === 'text') result += event.text;
|
if (event.type === 'text') result += event.text;
|
||||||
else if (event.type === 'error') return null;
|
else if (event.type === 'error') return null;
|
||||||
else if (event.type === 'done') break;
|
else if (event.type === 'done') break;
|
||||||
|
|||||||
@ -13,26 +13,57 @@ const validResult = {
|
|||||||
reasoning: 'x',
|
reasoning: 'x',
|
||||||
};
|
};
|
||||||
|
|
||||||
const okResponse = {
|
/**
|
||||||
|
* The reflection client now routes through OpenAICompatClient, which speaks
|
||||||
|
* streaming SSE. Build a fake streaming `Response` that emits the given SSE
|
||||||
|
* `data:` payloads, then `[DONE]`.
|
||||||
|
*/
|
||||||
|
function sseResponse(chunks: unknown[]): Response {
|
||||||
|
const lines = chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`);
|
||||||
|
lines.push('data: [DONE]\n\n');
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let i = 0;
|
||||||
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve({
|
status: 200,
|
||||||
|
headers: { get: () => null },
|
||||||
|
body: {
|
||||||
|
getReader: () => ({
|
||||||
|
read: async () =>
|
||||||
|
i < lines.length
|
||||||
|
? { done: false, value: encoder.encode(lines[i++]) }
|
||||||
|
: { done: true, value: undefined },
|
||||||
|
releaseLock: () => {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A complete, valid submit_reflection tool-call stream with usage. */
|
||||||
|
function okStream(args: unknown = validResult): Response {
|
||||||
|
return sseResponse([
|
||||||
|
{
|
||||||
|
model: 'test-model',
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
message: {
|
delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'submit_reflection', arguments: JSON.stringify(args) } }] },
|
||||||
tool_calls: [
|
finish_reason: null,
|
||||||
{
|
|
||||||
function: {
|
|
||||||
name: 'submit_reflection',
|
|
||||||
arguments: JSON.stringify(validResult),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
|
||||||
],
|
{ choices: [], usage: { prompt_tokens: 42, completion_tokens: 17 } },
|
||||||
usage: { prompt_tokens: 42, completion_tokens: 17 },
|
]);
|
||||||
}),
|
}
|
||||||
};
|
|
||||||
|
function httpError(status: number, bodyText: string): Response {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status,
|
||||||
|
headers: { get: () => null },
|
||||||
|
text: () => Promise.resolve(bodyText),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// No real backoff sleeps in tests.
|
// No real backoff sleeps in tests.
|
||||||
@ -45,7 +76,7 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe('callReflectionLlm', () => {
|
describe('callReflectionLlm', () => {
|
||||||
it('happy path: parses tool_call arguments and extracts token usage', async () => {
|
it('happy path: parses tool_call arguments and extracts token usage', async () => {
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okResponse));
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(okStream()));
|
||||||
|
|
||||||
const result = await callReflectionLlm(cfg, 'system prompt', 'user prompt');
|
const result = await callReflectionLlm(cfg, 'system prompt', 'user prompt');
|
||||||
|
|
||||||
@ -59,12 +90,8 @@ describe('callReflectionLlm', () => {
|
|||||||
|
|
||||||
it('retries a 5xx (backend tool-call parse failure) and succeeds on resample', async () => {
|
it('retries a 5xx (backend tool-call parse failure) and succeeds on resample', async () => {
|
||||||
const fetchMock = vi.fn()
|
const fetchMock = vi.fn()
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce(httpError(500, '{"error":{"message":"Failed to parse input at pos 41"}}'))
|
||||||
ok: false,
|
.mockResolvedValueOnce(okStream());
|
||||||
status: 500,
|
|
||||||
text: () => Promise.resolve('{"error":{"message":"Failed to parse input at pos 41: <tool_call>..."}}'),
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce(okResponse);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
const result = await callReflectionLlm(cfg, 's', 'u');
|
const result = await callReflectionLlm(cfg, 's', 'u');
|
||||||
@ -73,11 +100,7 @@ describe('callReflectionLlm', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('gives up after 3 attempts of persistent 5xx', async () => {
|
it('gives up after 3 attempts of persistent 5xx', async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue({
|
const fetchMock = vi.fn().mockResolvedValue(httpError(500, 'parse error'));
|
||||||
ok: false,
|
|
||||||
status: 500,
|
|
||||||
text: () => Promise.resolve('parse error'),
|
|
||||||
});
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
await expect(callReflectionLlm(cfg, 's', 'u')).rejects.toThrow('HTTP 500');
|
await expect(callReflectionLlm(cfg, 's', 'u')).rejects.toThrow('HTTP 500');
|
||||||
@ -85,38 +108,62 @@ describe('callReflectionLlm', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does NOT retry a 4xx (deterministic config error, e.g. invalid api key)', async () => {
|
it('does NOT retry a 4xx (deterministic config error, e.g. invalid api key)', async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue({
|
const fetchMock = vi.fn().mockResolvedValue(httpError(401, 'invalid api key'));
|
||||||
ok: false,
|
|
||||||
status: 401,
|
|
||||||
text: () => Promise.resolve('invalid api key'),
|
|
||||||
});
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
await expect(callReflectionLlm(cfg, 's', 'u')).rejects.toThrow('HTTP 401');
|
await expect(callReflectionLlm(cfg, 's', 'u')).rejects.toThrow('HTTP 401');
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retries when no tool_calls present, then throws after exhaustion', async () => {
|
it('retries when no tool_call present, then throws after exhaustion', async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue({
|
// A stream that yields only text and finishes — no submit_reflection call.
|
||||||
ok: true,
|
const noToolStream = () => sseResponse([
|
||||||
json: () => Promise.resolve({ choices: [{ message: {} }] }),
|
{ choices: [{ delta: { content: 'just text' }, finish_reason: 'stop' }] },
|
||||||
});
|
]);
|
||||||
|
const fetchMock = vi.fn().mockImplementation(async () => noToolStream());
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
await expect(callReflectionLlm(cfg, 'system prompt', 'user prompt'))
|
await expect(callReflectionLlm(cfg, 'system prompt', 'user prompt'))
|
||||||
.rejects.toThrow('no tool_call');
|
.rejects.toThrow('no submit_reflection tool_call');
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retries malformed tool_call arguments JSON', async () => {
|
it('does NOT retry a budget_exhausted gateway sentinel (fail fast)', async () => {
|
||||||
|
// SSE sentinel error → client yields gatewayErrorType=budget_exhausted.
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
sseResponse([{ error: { type: 'budget_exhausted', message: 'over quota' } }]),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await expect(callReflectionLlm(cfg, 's', 'u')).rejects.toThrow('budget_exhausted');
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT retry a prompt-size preflight block (fail fast)', async () => {
|
||||||
|
// Tiny context window forces the client preflight guard to block before
|
||||||
|
// any fetch; resampling the identical prompt cannot help.
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(okStream());
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await expect(callReflectionLlm({ ...cfg, contextLimitTokens: 1 }, 'system', 'user'))
|
||||||
|
.rejects.toThrow('blocked before send');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries malformed tool_call arguments (client yields empty input)', async () => {
|
||||||
|
// First stream carries broken JSON args → client parses to {} → structural
|
||||||
|
// guard treats it as malformed → resample. Second stream is valid.
|
||||||
|
const brokenStream = () => sseResponse([
|
||||||
|
{
|
||||||
|
choices: [
|
||||||
|
{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'submit_reflection', arguments: '{broken' } }] }, finish_reason: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
|
||||||
|
]);
|
||||||
const fetchMock = vi.fn()
|
const fetchMock = vi.fn()
|
||||||
.mockResolvedValueOnce({
|
.mockImplementationOnce(async () => brokenStream())
|
||||||
ok: true,
|
.mockImplementationOnce(async () => okStream());
|
||||||
json: () => Promise.resolve({
|
|
||||||
choices: [{ message: { tool_calls: [{ function: { name: 'submit_reflection', arguments: '{broken' } }] } }],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce(okResponse);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
const result = await callReflectionLlm(cfg, 's', 'u');
|
const result = await callReflectionLlm(cfg, 's', 'u');
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
|
import { getDefaultProviderRetryConfig } from '../../config.js';
|
||||||
|
import { OpenAICompatClient, type LLMEvent, type Message, type ToolDef } from '../../llm/openai-compat.js';
|
||||||
import type { ReflectionResult } from './types.js';
|
import type { ReflectionResult } from './types.js';
|
||||||
import { REFLECTION_TOOL_SCHEMA } from './reflection-schema.js';
|
import { REFLECTION_TOOL_SCHEMA } from './reflection-schema.js';
|
||||||
|
|
||||||
@ -6,6 +8,17 @@ export interface ReflectionLlmConfig {
|
|||||||
endpoint: string;
|
endpoint: string;
|
||||||
model: string | undefined;
|
model: string | undefined;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
|
/** True when the reflection worker routes through the AAO Gateway (proxy). */
|
||||||
|
proxy?: boolean;
|
||||||
|
/** Reflection target user — recorded as the usage owner. */
|
||||||
|
userId?: string;
|
||||||
|
/**
|
||||||
|
* Model context window in tokens. Passed to the shared client's
|
||||||
|
* prompt-size preflight guard. Reflection prompts can be large (uncapped
|
||||||
|
* memory snapshot), so use the worker's real limit rather than the
|
||||||
|
* client's conservative 32k default, which would block valid prompts.
|
||||||
|
*/
|
||||||
|
contextLimitTokens?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReflectionLlmResult {
|
export interface ReflectionLlmResult {
|
||||||
@ -62,54 +75,104 @@ export async function callReflectionLlm(
|
|||||||
throw lastErr ?? new Error('reflection LLM failed');
|
throw lastErr ?? new Error('reflection LLM failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify an OpenAICompatClient error for the reflection resample loop.
|
||||||
|
* - HTTP 5xx (incl. tool-call parse errors on malformed model output) and
|
||||||
|
* gateway_shutdown / gateway_timeout: transient → resample.
|
||||||
|
* - HTTP 4xx (bad key / request shape), budget_exhausted / rate_limited
|
||||||
|
* (won't pass until the period resets), and the client-side
|
||||||
|
* "blocked before send" prompt-size guard: deterministic → fail fast.
|
||||||
|
* - Everything else (transport / parse / idle timeout): stochastic → resample.
|
||||||
|
*/
|
||||||
|
function classifyClientError(message: string, gatewayErrorType?: string): Error {
|
||||||
|
if (gatewayErrorType === 'budget_exhausted' || gatewayErrorType === 'rate_limited') {
|
||||||
|
return new Error(message);
|
||||||
|
}
|
||||||
|
if (gatewayErrorType === 'gateway_shutdown' || gatewayErrorType === 'gateway_timeout') {
|
||||||
|
return new RetryableLlmError(message);
|
||||||
|
}
|
||||||
|
// Client-side preflight rejection — the prompt is too large; resampling the
|
||||||
|
// identical prompt cannot help.
|
||||||
|
if (message.includes('blocked before send')) {
|
||||||
|
return new Error(message);
|
||||||
|
}
|
||||||
|
const m = /HTTP (\d{3})/.exec(message);
|
||||||
|
if (m) {
|
||||||
|
const status = Number(m[1]);
|
||||||
|
if (status >= 500) return new RetryableLlmError(message);
|
||||||
|
return new Error(message);
|
||||||
|
}
|
||||||
|
return new RetryableLlmError(message);
|
||||||
|
}
|
||||||
|
|
||||||
async function callOnce(
|
async function callOnce(
|
||||||
cfg: ReflectionLlmConfig,
|
cfg: ReflectionLlmConfig,
|
||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
userPrompt: string,
|
userPrompt: string,
|
||||||
start: number,
|
start: number,
|
||||||
): Promise<ReflectionLlmResult> {
|
): Promise<ReflectionLlmResult> {
|
||||||
const body: Record<string, unknown> = {
|
// Route through the shared client so usage lands in the single
|
||||||
messages: [
|
// per-user ledger (gateway + direct) like every other LLM call.
|
||||||
|
// maxAttempts=1: the outer callReflectionLlm loop owns resampling.
|
||||||
|
const client = new OpenAICompatClient(
|
||||||
|
cfg.endpoint,
|
||||||
|
cfg.model,
|
||||||
|
cfg.apiKey,
|
||||||
|
{ ...getDefaultProviderRetryConfig(), maxAttempts: 1 },
|
||||||
|
undefined,
|
||||||
|
cfg.contextLimitTokens, // real model window; avoid the 32k default blocking large reflection prompts
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ proxy: cfg.proxy === true },
|
||||||
|
);
|
||||||
|
const messages: Message[] = [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
{ role: 'user', content: userPrompt },
|
{ role: 'user', content: userPrompt },
|
||||||
],
|
];
|
||||||
tools: [REFLECTION_TOOL_SCHEMA],
|
|
||||||
tool_choice: { type: 'function', function: { name: 'submit_reflection' } },
|
let parsed: ReflectionResult | null = null;
|
||||||
temperature: 0.2,
|
let usage: { prompt_tokens: number; completion_tokens: number } | undefined;
|
||||||
};
|
let errorMsg: string | null = null;
|
||||||
if (cfg.model) {
|
let errorGatewayType: string | undefined;
|
||||||
body['model'] = cfg.model;
|
|
||||||
|
for await (const event of client.chat(
|
||||||
|
messages,
|
||||||
|
[REFLECTION_TOOL_SCHEMA as unknown as ToolDef],
|
||||||
|
undefined,
|
||||||
|
{ userId: cfg.userId },
|
||||||
|
{ temperature: 0.2, toolChoice: { type: 'function', function: { name: 'submit_reflection' } } },
|
||||||
|
) as AsyncGenerator<LLMEvent>) {
|
||||||
|
if (event.type === 'tool_use') {
|
||||||
|
if (event.name === 'submit_reflection' && parsed === null) {
|
||||||
|
parsed = event.input as unknown as ReflectionResult;
|
||||||
}
|
}
|
||||||
const resp = await fetch(`${cfg.endpoint}/chat/completions`, {
|
} else if (event.type === 'done') {
|
||||||
method: 'POST',
|
usage = event.usage;
|
||||||
headers: {
|
} else if (event.type === 'error') {
|
||||||
'content-type': 'application/json',
|
errorMsg = event.error;
|
||||||
...(cfg.apiKey ? { authorization: `Bearer ${cfg.apiKey}` } : {}),
|
errorGatewayType = event.gatewayErrorType;
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
const text = await resp.text();
|
|
||||||
const msg = `reflection LLM HTTP ${resp.status}: ${text}`;
|
|
||||||
// 5xx: backend-side failure (incl. tool-call parse errors on malformed
|
|
||||||
// model output) — resample. 4xx: deterministic config error — fail fast.
|
|
||||||
if (resp.status >= 500) throw new RetryableLlmError(msg);
|
|
||||||
throw new Error(msg);
|
|
||||||
}
|
}
|
||||||
const data = await resp.json() as any;
|
}
|
||||||
const toolCall = data.choices?.[0]?.message?.tool_calls?.[0];
|
|
||||||
if (!toolCall) throw new RetryableLlmError('reflection LLM returned no tool_call');
|
if (errorMsg !== null) {
|
||||||
let parsed: ReflectionResult;
|
throw classifyClientError(`reflection LLM ${errorMsg}`, errorGatewayType);
|
||||||
try {
|
}
|
||||||
parsed = JSON.parse(toolCall.function.arguments) as ReflectionResult;
|
if (parsed === null) {
|
||||||
} catch {
|
throw new RetryableLlmError('reflection LLM returned no submit_reflection tool_call');
|
||||||
throw new RetryableLlmError('reflection LLM tool_call arguments were not valid JSON');
|
}
|
||||||
|
// The shared client swallows tool-argument JSON parse errors and yields an
|
||||||
|
// empty `{}` input. Preserve the old resample-on-malformed behaviour with a
|
||||||
|
// shallow structural check against the tool schema's required fields — a
|
||||||
|
// genuinely-empty object means the model emitted broken tool markup.
|
||||||
|
const p = parsed as unknown as Record<string, unknown>;
|
||||||
|
if (p['piece_changes'] === undefined || p['reasoning'] === undefined) {
|
||||||
|
throw new RetryableLlmError('reflection LLM tool_call arguments were malformed or incomplete');
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
parsed,
|
parsed,
|
||||||
tokensIn: data.usage?.prompt_tokens ?? 0,
|
tokensIn: usage?.prompt_tokens ?? 0,
|
||||||
tokensOut: data.usage?.completion_tokens ?? 0,
|
tokensOut: usage?.completion_tokens ?? 0,
|
||||||
durationMs: Date.now() - start,
|
durationMs: Date.now() - start,
|
||||||
raw: data,
|
raw: { usage },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,10 @@ export interface RunReflectionDeps {
|
|||||||
* (normal task calls always send the worker's key — reflection must too).
|
* (normal task calls always send the worker's key — reflection must too).
|
||||||
*/
|
*/
|
||||||
llmApiKey?: string;
|
llmApiKey?: string;
|
||||||
|
/** True when the reflection worker routes through the AAO Gateway (proxy). */
|
||||||
|
llmProxy?: boolean;
|
||||||
|
/** Reflection worker's model context window (tokens) for the prompt guard. */
|
||||||
|
llmContextLimitTokens?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runReflectionJob(
|
export async function runReflectionJob(
|
||||||
@ -86,6 +90,9 @@ export async function runReflectionJob(
|
|||||||
endpoint: deps.llmEndpoint,
|
endpoint: deps.llmEndpoint,
|
||||||
model: deps.llmModel,
|
model: deps.llmModel,
|
||||||
apiKey: deps.llmApiKey,
|
apiKey: deps.llmApiKey,
|
||||||
|
proxy: deps.llmProxy === true,
|
||||||
|
userId: meta.userId,
|
||||||
|
contextLimitTokens: deps.llmContextLimitTokens,
|
||||||
};
|
};
|
||||||
|
|
||||||
let llmResult;
|
let llmResult;
|
||||||
|
|||||||
BIN
src/engine/tools/__fixtures__/attachmentFiles.msg
Normal file
BIN
src/engine/tools/__fixtures__/attachmentFiles.msg
Normal file
Binary file not shown.
@ -51,6 +51,7 @@ export interface ToolsConfig {
|
|||||||
officePdfMaxSizeMb?: number; // ReadPdf の最大ファイルサイズ (default: 10)
|
officePdfMaxSizeMb?: number; // ReadPdf の最大ファイルサイズ (default: 10)
|
||||||
officePptxMaxSizeMb?: number; // ReadPPTX の最大ファイルサイズ (default: 50)
|
officePptxMaxSizeMb?: number; // ReadPPTX の最大ファイルサイズ (default: 50)
|
||||||
officePptxMaxUncompressedMb?: number; // ReadPPTX の ZIP 展開後サイズ上限 (default: 200)
|
officePptxMaxUncompressedMb?: number; // ReadPPTX の ZIP 展開後サイズ上限 (default: 200)
|
||||||
|
officeMsgMaxSizeMb?: number; // ReadMsg の最大ファイルサイズ (default: 25)
|
||||||
webfetchScreenshot?: boolean; // WebFetch で vlmEnabled 時にスクショを添付するか (default: true)
|
webfetchScreenshot?: boolean; // WebFetch で vlmEnabled 時にスクショを添付するか (default: true)
|
||||||
webfetchScreenshotTimeoutMs?: number; // スクショ取得のタイムアウト (default: 15000)
|
webfetchScreenshotTimeoutMs?: number; // スクショ取得のタイムアウト (default: 15000)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,6 +52,7 @@ const TOOL_DOC_ALIASES: Record<string, string> = {
|
|||||||
readexcel: 'office',
|
readexcel: 'office',
|
||||||
readdocx: 'office',
|
readdocx: 'office',
|
||||||
readpptx: 'office',
|
readpptx: 'office',
|
||||||
|
readmsg: 'office',
|
||||||
pdftoimages: 'office',
|
pdftoimages: 'office',
|
||||||
splitexcelsheets: 'office',
|
splitexcelsheets: 'office',
|
||||||
splitdocxsections: 'office',
|
splitdocxsections: 'office',
|
||||||
|
|||||||
324
src/engine/tools/msg.test.ts
Normal file
324
src/engine/tools/msg.test.ts
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import {
|
||||||
|
formatAddress,
|
||||||
|
stripHtml,
|
||||||
|
selectMsgBody,
|
||||||
|
sanitizeAttachmentName,
|
||||||
|
formatMsgOutput,
|
||||||
|
assembleMsgOutput,
|
||||||
|
pickEmail,
|
||||||
|
isParsedMsgValid,
|
||||||
|
executeReadMsg,
|
||||||
|
type MsgView,
|
||||||
|
} from './msg.js';
|
||||||
|
import type { ToolContext } from './core.js';
|
||||||
|
import { executeTool as officeExecuteTool, TOOL_DEFS as OFFICE_TOOL_DEFS } from './office.js';
|
||||||
|
|
||||||
|
const FIXTURE = path.join(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
'__fixtures__',
|
||||||
|
'attachmentFiles.msg',
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('formatAddress', () => {
|
||||||
|
it('renders name and email together', () => {
|
||||||
|
expect(formatAddress({ name: 'Alice', email: 'alice@example.com' })).toBe(
|
||||||
|
'Alice <alice@example.com>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders name only when email is missing', () => {
|
||||||
|
expect(formatAddress({ name: 'Alice' })).toBe('Alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders email only when name is missing', () => {
|
||||||
|
expect(formatAddress({ email: 'alice@example.com' })).toBe('alice@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a placeholder when both are missing', () => {
|
||||||
|
expect(formatAddress({})).toBe('(unknown)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stripHtml', () => {
|
||||||
|
it('removes tags and decodes entities', () => {
|
||||||
|
expect(stripHtml('<p>Hello <b>world</b> & co</p>')).toBe('Hello world & co');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops script and style content', () => {
|
||||||
|
const html = '<style>.x{color:red}</style><p>Keep</p><script>alert(1)</script>';
|
||||||
|
expect(stripHtml(html)).toBe('Keep');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns block boundaries into newlines', () => {
|
||||||
|
expect(stripHtml('<div>line1</div><div>line2</div>')).toBe('line1\nline2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes valid numeric entities', () => {
|
||||||
|
expect(stripHtml('<p>AB</p>')).toBe('AB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw on out-of-range numeric entities', () => {
|
||||||
|
expect(() => stripHtml('<p>�</p>')).not.toThrow();
|
||||||
|
expect(stripHtml('A�B')).toBe('A�B');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('selectMsgBody', () => {
|
||||||
|
it('prefers the plain-text body', () => {
|
||||||
|
expect(selectMsgBody({ body: 'plain text', bodyHtml: '<p>html</p>' })).toEqual({
|
||||||
|
text: 'plain text',
|
||||||
|
format: 'plain',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to stripped HTML when no plain body exists', () => {
|
||||||
|
expect(selectMsgBody({ bodyHtml: '<p>html body</p>' })).toEqual({
|
||||||
|
text: 'html body',
|
||||||
|
format: 'html',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports none when no body is present', () => {
|
||||||
|
expect(selectMsgBody({})).toEqual({ text: '', format: 'none' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes PidTagHtml (html) when body and bodyHtml are absent', () => {
|
||||||
|
const html = new TextEncoder().encode('<p>from pidtag</p>');
|
||||||
|
expect(selectMsgBody({ html })).toEqual({ text: 'from pidtag', format: 'html' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers plain body over the PidTagHtml field', () => {
|
||||||
|
const html = new TextEncoder().encode('<p>html</p>');
|
||||||
|
expect(selectMsgBody({ body: 'plain', html })).toEqual({ text: 'plain', format: 'plain' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to PidTagHtml when bodyHtml is empty/whitespace', () => {
|
||||||
|
const html = new TextEncoder().encode('<p>pidtag body</p>');
|
||||||
|
expect(selectMsgBody({ bodyHtml: ' ', html })).toEqual({
|
||||||
|
text: 'pidtag body',
|
||||||
|
format: 'html',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pickEmail', () => {
|
||||||
|
it('prefers a real SMTP address over a legacy EX DN', () => {
|
||||||
|
expect(pickEmail('/O=EX/OU=x/CN=alice', 'alice@example.com')).toBe('alice@example.com');
|
||||||
|
expect(pickEmail('alice@example.com', '/O=EX/OU=x/CN=alice')).toBe('alice@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the EX DN when no SMTP-looking address exists', () => {
|
||||||
|
expect(pickEmail(undefined, '/O=EX/OU=x/CN=alice')).toBe('/O=EX/OU=x/CN=alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when nothing usable is provided', () => {
|
||||||
|
expect(pickEmail(undefined, undefined)).toBeUndefined();
|
||||||
|
expect(pickEmail('', ' ')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isParsedMsgValid', () => {
|
||||||
|
it('accepts a parsed Outlook message', () => {
|
||||||
|
expect(isParsedMsgValid({ dataType: 'msg' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unsupported CFBF result (old .doc/.xls, corrupted compound file)', () => {
|
||||||
|
expect(isParsedMsgValid({ error: 'Unsupported file type!', dataType: null })).toBe(false);
|
||||||
|
expect(isParsedMsgValid({ dataType: null })).toBe(false);
|
||||||
|
expect(isParsedMsgValid({ dataType: 'attachment' })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a whitespace-only plain body as empty and uses HTML', () => {
|
||||||
|
expect(selectMsgBody({ body: ' \n ', bodyHtml: '<p>real</p>' })).toEqual({
|
||||||
|
text: 'real',
|
||||||
|
format: 'html',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeAttachmentName', () => {
|
||||||
|
it('keeps a normal filename unchanged', () => {
|
||||||
|
expect(sanitizeAttachmentName('report.pdf', 0)).toBe('report.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips directory components to prevent path traversal', () => {
|
||||||
|
expect(sanitizeAttachmentName('../../etc/passwd', 0)).toBe('passwd');
|
||||||
|
expect(sanitizeAttachmentName('foo/bar/baz.txt', 0)).toBe('baz.txt');
|
||||||
|
expect(sanitizeAttachmentName('a\\b\\c.doc', 0)).toBe('c.doc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes control characters and null bytes', () => {
|
||||||
|
expect(sanitizeAttachmentName('na\x00me.txt', 0)).toBe('name.txt');
|
||||||
|
expect(sanitizeAttachmentName('tab\tname.txt', 0)).toBe('tabname.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves spaces inside the filename', () => {
|
||||||
|
expect(sanitizeAttachmentName('my report.pdf', 0)).toBe('my report.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to an indexed name when the result is empty', () => {
|
||||||
|
expect(sanitizeAttachmentName('', 2)).toBe('attachment-3');
|
||||||
|
expect(sanitizeAttachmentName('...', 0)).toBe('attachment-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatMsgOutput', () => {
|
||||||
|
const baseView: MsgView = {
|
||||||
|
subject: 'Quarterly report',
|
||||||
|
from: { name: 'Alice', email: 'alice@example.com' },
|
||||||
|
to: [{ name: 'Bob', email: 'bob@example.com' }],
|
||||||
|
cc: [],
|
||||||
|
date: 'Mon, 1 Jun 2026 10:00:00 +0900',
|
||||||
|
body: { text: 'See attached.', format: 'plain' },
|
||||||
|
attachments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('renders the header block and body', () => {
|
||||||
|
const out = formatMsgOutput(baseView);
|
||||||
|
expect(out).toContain('Subject: Quarterly report');
|
||||||
|
expect(out).toContain('From: Alice <alice@example.com>');
|
||||||
|
expect(out).toContain('To: Bob <bob@example.com>');
|
||||||
|
expect(out).toContain('Date: Mon, 1 Jun 2026 10:00:00 +0900');
|
||||||
|
expect(out).toContain('See attached.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists saved attachments with their paths and sizes', () => {
|
||||||
|
const out = formatMsgOutput({
|
||||||
|
...baseView,
|
||||||
|
attachments: [{ fileName: 'report.pdf', contentLength: 2048, savedPath: 'input/report.pdf' }],
|
||||||
|
});
|
||||||
|
expect(out).toContain('Attachments (1)');
|
||||||
|
expect(out).toContain('report.pdf');
|
||||||
|
expect(out).toContain('input/report.pdf');
|
||||||
|
expect(out).toContain('2048');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a skip reason for attachments that were not saved', () => {
|
||||||
|
const out = formatMsgOutput({
|
||||||
|
...baseView,
|
||||||
|
attachments: [{ fileName: 'huge.bin', skipped: 'exceeds size limit' }],
|
||||||
|
});
|
||||||
|
expect(out).toContain('huge.bin');
|
||||||
|
expect(out).toContain('exceeds size limit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notes when the body could not be extracted', () => {
|
||||||
|
const out = formatMsgOutput({ ...baseView, body: { text: '', format: 'none' } });
|
||||||
|
expect(out).toContain('(no text body)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the CC line when there are no CC recipients', () => {
|
||||||
|
expect(formatMsgOutput(baseView)).not.toContain('Cc:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the attachment list when the body is truncated to budget', () => {
|
||||||
|
const longBody = 'word '.repeat(20000);
|
||||||
|
const out = assembleMsgOutput(
|
||||||
|
{
|
||||||
|
...baseView,
|
||||||
|
body: { text: longBody, format: 'plain' },
|
||||||
|
attachments: [{ fileName: 'a.pdf', contentLength: 10, savedPath: 'input/a.pdf' }],
|
||||||
|
},
|
||||||
|
100,
|
||||||
|
'mail.msg',
|
||||||
|
);
|
||||||
|
expect(out).toContain('input/a.pdf');
|
||||||
|
expect(out).toContain('Subject: Quarterly report');
|
||||||
|
expect(out.length).toBeLessThan(longBody.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the CC line when CC recipients exist', () => {
|
||||||
|
const out = formatMsgOutput({ ...baseView, cc: [{ email: 'carol@example.com' }] });
|
||||||
|
expect(out).toContain('Cc: carol@example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('executeReadMsg (integration)', () => {
|
||||||
|
let workspace: string;
|
||||||
|
const ctx = (): ToolContext => ({ workspacePath: workspace, editAllowed: true });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'readmsg-'));
|
||||||
|
fs.copyFileSync(FIXTURE, path.join(workspace, 'mail.msg'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts headers and body from a real .msg file', async () => {
|
||||||
|
const result = await executeReadMsg({ file_path: 'mail.msg' }, ctx());
|
||||||
|
expect(result.isError).toBeFalsy();
|
||||||
|
expect(result.output).toContain('Subject: attachmentFiles');
|
||||||
|
expect(result.output).toContain('From: hmailuser <hmailuser@hmailserver.test>');
|
||||||
|
expect(result.output).toContain('To: hmailuser@hmailserver.test');
|
||||||
|
expect(result.output).toContain('attachmentFiles');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves attachments to input/ and lists them', async () => {
|
||||||
|
const result = await executeReadMsg({ file_path: 'mail.msg' }, ctx());
|
||||||
|
expect(result.output).toContain('Attachments (3)');
|
||||||
|
for (const [name, size] of [
|
||||||
|
['jpg.jpg', 726],
|
||||||
|
['png.png', 134],
|
||||||
|
['tif.tif', 664],
|
||||||
|
] as const) {
|
||||||
|
const saved = path.join(workspace, 'input', name);
|
||||||
|
expect(fs.existsSync(saved)).toBe(true);
|
||||||
|
expect(fs.statSync(saved).size).toBe(size);
|
||||||
|
expect(result.output).toContain(path.join('input', name));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects paths outside the workspace', async () => {
|
||||||
|
const result = await executeReadMsg({ file_path: '../../etc/passwd' }, ctx());
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a clear error for a non-.msg file', async () => {
|
||||||
|
fs.writeFileSync(path.join(workspace, 'junk.msg'), 'not a real msg file');
|
||||||
|
const result = await executeReadMsg({ file_path: 'junk.msg' }, ctx());
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
expect(result.output).toContain('ReadMsg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not write attachments in a read-only phase', async () => {
|
||||||
|
const result = await executeReadMsg(
|
||||||
|
{ file_path: 'mail.msg' },
|
||||||
|
{ workspacePath: workspace, editAllowed: false },
|
||||||
|
);
|
||||||
|
expect(result.isError).toBeFalsy();
|
||||||
|
expect(fs.existsSync(path.join(workspace, 'input', 'jpg.jpg'))).toBe(false);
|
||||||
|
expect(result.output).toContain('read-only');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite an existing file in input/', async () => {
|
||||||
|
fs.mkdirSync(path.join(workspace, 'input'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(workspace, 'input', 'jpg.jpg'), 'pre-existing');
|
||||||
|
const result = await executeReadMsg({ file_path: 'mail.msg' }, ctx());
|
||||||
|
expect(fs.readFileSync(path.join(workspace, 'input', 'jpg.jpg'), 'utf8')).toBe('pre-existing');
|
||||||
|
expect(fs.existsSync(path.join(workspace, 'input', 'jpg-1.jpg'))).toBe(true);
|
||||||
|
expect(result.output).toContain('jpg-1.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects files exceeding the configured size limit', async () => {
|
||||||
|
const result = await executeReadMsg(
|
||||||
|
{ file_path: 'mail.msg' },
|
||||||
|
{ workspacePath: workspace, editAllowed: true, toolsConfig: { officeMsgMaxSizeMb: 0.001 } },
|
||||||
|
);
|
||||||
|
expect(result.isError).toBe(true);
|
||||||
|
expect(result.output).toMatch(/size|limit|too large/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is registered and routed through the office module dispatch', async () => {
|
||||||
|
expect(OFFICE_TOOL_DEFS.ReadMsg).toBeDefined();
|
||||||
|
const result = await officeExecuteTool('ReadMsg', { file_path: 'mail.msg' }, ctx());
|
||||||
|
expect(result?.isError).toBeFalsy();
|
||||||
|
expect(result?.output).toContain('Subject: attachmentFiles');
|
||||||
|
});
|
||||||
|
});
|
||||||
416
src/engine/tools/msg.ts
Normal file
416
src/engine/tools/msg.ts
Normal file
@ -0,0 +1,416 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import MsgReaderImport from '@kenjiuno/msgreader';
|
||||||
|
import { ToolDef } from '../../llm/openai-compat.js';
|
||||||
|
import type { ToolContext, ToolResult } from './core.js';
|
||||||
|
import { resolveAndGuard, truncateToBudget, getToolOutputBudgetTokens } from './core.js';
|
||||||
|
import { logger } from '../../logger.js';
|
||||||
|
|
||||||
|
// CJS/ESM interop: under native Node ESM (the built dist), a default import of
|
||||||
|
// this CommonJS package resolves to the module.exports namespace object, not the
|
||||||
|
// class — so `new MsgReaderImport()` throws "is not a constructor". Vitest/tsx
|
||||||
|
// hide this via __esModule interop. Pick the real constructor for both worlds.
|
||||||
|
const MsgReader = (
|
||||||
|
typeof MsgReaderImport === 'function'
|
||||||
|
? MsgReaderImport
|
||||||
|
: (MsgReaderImport as unknown as { default: typeof MsgReaderImport }).default
|
||||||
|
) as typeof MsgReaderImport;
|
||||||
|
type MsgReaderInstance = InstanceType<typeof MsgReader>;
|
||||||
|
|
||||||
|
const DEFAULT_MSG_MAX_SIZE_MB = 25;
|
||||||
|
|
||||||
|
export interface MsgAddress {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MsgAttachmentMeta {
|
||||||
|
fileName: string;
|
||||||
|
contentLength?: number;
|
||||||
|
/** Relative path the attachment was written to (when saved). */
|
||||||
|
savedPath?: string;
|
||||||
|
/** Reason the attachment was not saved (mutually exclusive with savedPath). */
|
||||||
|
skipped?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MsgView {
|
||||||
|
subject?: string;
|
||||||
|
from?: MsgAddress;
|
||||||
|
to: MsgAddress[];
|
||||||
|
cc: MsgAddress[];
|
||||||
|
date?: string;
|
||||||
|
body: { text: string; format: 'plain' | 'html' | 'none' };
|
||||||
|
attachments: MsgAttachmentMeta[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a single address as `Name <email>`, falling back gracefully. */
|
||||||
|
export function formatAddress(a: MsgAddress): string {
|
||||||
|
const name = a.name?.trim();
|
||||||
|
const email = a.email?.trim();
|
||||||
|
if (name && email) return `${name} <${email}>`;
|
||||||
|
if (name) return name;
|
||||||
|
if (email) return email;
|
||||||
|
return '(unknown)';
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAMED_ENTITIES: Record<string, string> = {
|
||||||
|
' ': ' ',
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
''': "'",
|
||||||
|
''': "'",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decode a numeric character reference, preserving the original entity if the
|
||||||
|
// code point is out of range (broken email HTML must not crash the whole read).
|
||||||
|
function safeFromCodePoint(code: number, original: string): string {
|
||||||
|
if (!Number.isFinite(code) || code < 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) {
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return String.fromCodePoint(code);
|
||||||
|
} catch {
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeEntities(s: string): string {
|
||||||
|
let out = s.replace(/ |&|<|>|"|'|'/g, (m) => NAMED_ENTITIES[m]);
|
||||||
|
out = out.replace(/&#(\d+);/g, (m, code) => safeFromCodePoint(Number(code), m));
|
||||||
|
out = out.replace(/&#x([0-9a-fA-F]+);/g, (m, code) => safeFromCodePoint(parseInt(code, 16), m));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert an HTML fragment into readable plain text. */
|
||||||
|
export function stripHtml(html: string): string {
|
||||||
|
let s = html;
|
||||||
|
// Drop script/style blocks including their contents.
|
||||||
|
s = s.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, '');
|
||||||
|
// Treat <br> and block-level boundaries as newlines.
|
||||||
|
s = s.replace(/<br\s*\/?>/gi, '\n');
|
||||||
|
s = s.replace(/<\/(p|div|li|tr|h[1-6]|ul|ol|table|blockquote|section|article)\s*>/gi, '\n');
|
||||||
|
// Remove all remaining tags.
|
||||||
|
s = s.replace(/<[^>]+>/g, '');
|
||||||
|
s = decodeEntities(s);
|
||||||
|
// Normalize whitespace: collapse intra-line runs, trim each line, collapse blank runs.
|
||||||
|
s = s
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.replace(/[^\S\n]+/g, ' ').trim())
|
||||||
|
.join('\n')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Choose the best available body text, preferring plain over HTML. */
|
||||||
|
export function selectMsgBody(fields: {
|
||||||
|
body?: string;
|
||||||
|
bodyHtml?: string;
|
||||||
|
// PidTagHtml: some HTML-only messages carry the body here as raw bytes.
|
||||||
|
html?: Uint8Array | string;
|
||||||
|
}): {
|
||||||
|
text: string;
|
||||||
|
format: 'plain' | 'html' | 'none';
|
||||||
|
} {
|
||||||
|
const plain = fields.body?.trim();
|
||||||
|
if (plain) return { text: plain, format: 'plain' };
|
||||||
|
// Try each HTML source in order; an empty/whitespace bodyHtml must not block
|
||||||
|
// the PidTagHtml fallback, so we check the stripped result of each.
|
||||||
|
const htmlSources = [
|
||||||
|
fields.bodyHtml,
|
||||||
|
fields.html != null
|
||||||
|
? typeof fields.html === 'string'
|
||||||
|
? fields.html
|
||||||
|
: Buffer.from(fields.html).toString('utf8')
|
||||||
|
: undefined,
|
||||||
|
];
|
||||||
|
for (const source of htmlSources) {
|
||||||
|
if (!source) continue;
|
||||||
|
const stripped = stripHtml(source);
|
||||||
|
if (stripped) return { text: stripped, format: 'html' };
|
||||||
|
}
|
||||||
|
return { text: '', format: 'none' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the most usable email address from candidates, preferring a real SMTP
|
||||||
|
* address (contains '@') over an Exchange legacy EX DN (`/O=.../CN=...`).
|
||||||
|
*/
|
||||||
|
export function pickEmail(...candidates: (string | undefined)[]): string | undefined {
|
||||||
|
const valid = candidates.map((c) => c?.trim()).filter((c): c is string => !!c);
|
||||||
|
return valid.find((c) => c.includes('@')) ?? valid[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reduce an attachment name to a safe basename, never escaping the target dir. */
|
||||||
|
export function sanitizeAttachmentName(name: string, index: number): string {
|
||||||
|
// Take the last path segment across both separators (defends path traversal).
|
||||||
|
const base = name.split(/[/\\]/).pop() ?? '';
|
||||||
|
// Strip control characters and null bytes.
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const cleaned = base.replace(/[\x00-\x1f\x7f]/g, '').trim();
|
||||||
|
// Reject names that are empty or consist only of dots/spaces.
|
||||||
|
if (!cleaned || /^[.\s]*$/.test(cleaned)) {
|
||||||
|
return `attachment-${index + 1}`;
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find a filename that collides with neither this run nor existing files on disk. */
|
||||||
|
function resolveFreeName(dir: string, name: string, used: Set<string>): string {
|
||||||
|
const ext = path.extname(name);
|
||||||
|
const stem = name.slice(0, name.length - ext.length);
|
||||||
|
let candidate = name;
|
||||||
|
let n = 1;
|
||||||
|
while (used.has(candidate) || fs.existsSync(path.join(dir, candidate))) {
|
||||||
|
candidate = `${stem}-${n}${ext}`;
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the human-readable text output for a parsed message. */
|
||||||
|
export function formatMsgOutput(view: MsgView): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (view.subject) lines.push(`Subject: ${view.subject}`);
|
||||||
|
if (view.from) lines.push(`From: ${formatAddress(view.from)}`);
|
||||||
|
if (view.to.length) lines.push(`To: ${view.to.map(formatAddress).join(', ')}`);
|
||||||
|
if (view.cc.length) lines.push(`Cc: ${view.cc.map(formatAddress).join(', ')}`);
|
||||||
|
if (view.date) lines.push(`Date: ${view.date}`);
|
||||||
|
|
||||||
|
const parts: string[] = [lines.join('\n')];
|
||||||
|
|
||||||
|
parts.push(view.body.format === 'none' ? '(no text body)' : view.body.text);
|
||||||
|
|
||||||
|
if (view.attachments.length) {
|
||||||
|
const attLines = [`Attachments (${view.attachments.length}):`];
|
||||||
|
for (const att of view.attachments) {
|
||||||
|
if (att.savedPath) {
|
||||||
|
const size = att.contentLength != null ? ` (${att.contentLength} bytes)` : '';
|
||||||
|
attLines.push(`- ${att.fileName}${size} -> ${att.savedPath}`);
|
||||||
|
} else {
|
||||||
|
attLines.push(`- ${att.fileName} - skipped: ${att.skipped ?? 'not saved'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push(attLines.join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble the final output, truncating ONLY the body to the token budget.
|
||||||
|
* Headers and the attachment list (with saved input/ paths) always survive —
|
||||||
|
* attachments are already written to disk and the caller needs their paths.
|
||||||
|
*/
|
||||||
|
export function assembleMsgOutput(view: MsgView, budgetTokens: number, sourceLabel: string): string {
|
||||||
|
const shell = formatMsgOutput({ ...view, body: { text: '', format: 'plain' } });
|
||||||
|
const reserveTokens = Math.ceil(shell.length / 4) + 64;
|
||||||
|
const bodyBudget = Math.max(500, budgetTokens - reserveTokens);
|
||||||
|
const bodyText = view.body.format === 'none' ? '' : view.body.text;
|
||||||
|
const truncatedBody = truncateToBudget(bodyText, bodyBudget, { sourceLabel }).text;
|
||||||
|
return formatMsgOutput({ ...view, body: { text: truncatedBody, format: view.body.format } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* msgreader's getFileData() returns `{ error: 'Unsupported file type!' }` (not a
|
||||||
|
* throw) for CFBF files that aren't Outlook messages (legacy .doc/.xls, broken
|
||||||
|
* compound files). Treat anything whose root isn't a 'msg' as a read failure.
|
||||||
|
*/
|
||||||
|
export function isParsedMsgValid(fields: { error?: string; dataType?: string | null }): boolean {
|
||||||
|
return !fields.error && fields.dataType === 'msg';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const READ_MSG_DEF: ToolDef = {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'ReadMsg',
|
||||||
|
description:
|
||||||
|
'Read an Outlook .msg email file, extracting subject/sender/recipients/body and saving attachments to input/. 詳細は ReadToolDoc({ name: "ReadMsg" }) で取得可能。',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file_path: { type: 'string', description: 'Path to the .msg file' },
|
||||||
|
},
|
||||||
|
required: ['file_path'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FieldsLike {
|
||||||
|
error?: string;
|
||||||
|
dataType?: string | null;
|
||||||
|
subject?: string;
|
||||||
|
senderName?: string;
|
||||||
|
senderEmail?: string;
|
||||||
|
senderSmtpAddress?: string;
|
||||||
|
body?: string;
|
||||||
|
bodyHtml?: string;
|
||||||
|
html?: Uint8Array;
|
||||||
|
messageDeliveryTime?: string;
|
||||||
|
clientSubmitTime?: string;
|
||||||
|
recipients?: { name?: string; email?: string; smtpAddress?: string; recipType?: string }[];
|
||||||
|
attachments?: {
|
||||||
|
fileName?: string;
|
||||||
|
fileNameShort?: string;
|
||||||
|
contentLength?: number;
|
||||||
|
innerMsgContent?: boolean;
|
||||||
|
dataType?: string | null;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeReadMsg(
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
ctx: ToolContext,
|
||||||
|
): Promise<ToolResult> {
|
||||||
|
const filePath = String(input.file_path ?? '');
|
||||||
|
if (!filePath) {
|
||||||
|
return { output: 'ReadMsg: file_path is required', isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved: string;
|
||||||
|
try {
|
||||||
|
resolved = resolveAndGuard(ctx.workspacePath, filePath);
|
||||||
|
} catch (e) {
|
||||||
|
return { output: `ReadMsg: ${(e as Error).message}`, isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce a size cap before loading the whole file into memory (matches the
|
||||||
|
// other office tools, which each guard against oversized inputs).
|
||||||
|
const maxMb =
|
||||||
|
typeof ctx.toolsConfig?.officeMsgMaxSizeMb === 'number' &&
|
||||||
|
Number.isFinite(ctx.toolsConfig.officeMsgMaxSizeMb) &&
|
||||||
|
ctx.toolsConfig.officeMsgMaxSizeMb > 0
|
||||||
|
? ctx.toolsConfig.officeMsgMaxSizeMb
|
||||||
|
: DEFAULT_MSG_MAX_SIZE_MB;
|
||||||
|
try {
|
||||||
|
const sizeMb = fs.statSync(resolved).size / 1024 / 1024;
|
||||||
|
if (sizeMb > maxMb) {
|
||||||
|
return {
|
||||||
|
output: `ReadMsg: file size ${sizeMb.toFixed(1)}MB exceeds limit of ${maxMb}MB`,
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return { output: `ReadMsg: cannot stat file: ${(e as Error).message}`, isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
let buffer: Buffer;
|
||||||
|
try {
|
||||||
|
buffer = fs.readFileSync(resolved);
|
||||||
|
} catch (e) {
|
||||||
|
return { output: `ReadMsg: cannot read file: ${(e as Error).message}`, isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// .msg is an OLE2 / CFBF compound file. Validate the magic header up front:
|
||||||
|
// MsgReader silently returns an empty result for non-CFBF data instead of throwing.
|
||||||
|
const CFBF_MAGIC = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
|
||||||
|
if (buffer.length < 8 || !buffer.subarray(0, 8).equals(CFBF_MAGIC)) {
|
||||||
|
return {
|
||||||
|
output: `ReadMsg: not a valid Outlook .msg file (bad signature): ${path.basename(resolved)}`,
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let reader: MsgReaderInstance;
|
||||||
|
let fields: FieldsLike;
|
||||||
|
try {
|
||||||
|
// Copy into a standalone ArrayBuffer (MsgReader rejects Node Buffers).
|
||||||
|
const arrayBuffer = new Uint8Array(buffer).buffer;
|
||||||
|
reader = new MsgReader(arrayBuffer);
|
||||||
|
fields = reader.getFileData() as unknown as FieldsLike;
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
output: `ReadMsg: failed to parse .msg (is this a valid Outlook message?): ${(e as Error).message}`,
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isParsedMsgValid(fields)) {
|
||||||
|
return {
|
||||||
|
output: `ReadMsg: not a parseable Outlook message${fields.error ? ` (${fields.error})` : ''}: ${path.basename(resolved)}`,
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipients = fields.recipients ?? [];
|
||||||
|
const to = recipients
|
||||||
|
.filter((r) => (r.recipType ?? 'to') === 'to')
|
||||||
|
.map((r) => ({ name: r.name, email: pickEmail(r.smtpAddress, r.email) }));
|
||||||
|
const cc = recipients
|
||||||
|
.filter((r) => r.recipType === 'cc')
|
||||||
|
.map((r) => ({ name: r.name, email: pickEmail(r.smtpAddress, r.email) }));
|
||||||
|
|
||||||
|
const inputDir = path.join(ctx.workspacePath, 'input');
|
||||||
|
const attachments: MsgAttachmentMeta[] = [];
|
||||||
|
const rawAttachments = fields.attachments ?? [];
|
||||||
|
const usedNames = new Set<string>();
|
||||||
|
|
||||||
|
rawAttachments.forEach((att, i) => {
|
||||||
|
const rawName = att.fileName || att.fileNameShort || '';
|
||||||
|
const baseName = sanitizeAttachmentName(rawName, i);
|
||||||
|
|
||||||
|
// Read-only movements (verify etc.) must not mutate the workspace.
|
||||||
|
if (!ctx.editAllowed) {
|
||||||
|
attachments.push({
|
||||||
|
fileName: baseName,
|
||||||
|
contentLength: att.contentLength,
|
||||||
|
skipped: 'read-only phase (attachment not saved)',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (att.innerMsgContent) {
|
||||||
|
attachments.push({
|
||||||
|
fileName: baseName,
|
||||||
|
contentLength: att.contentLength,
|
||||||
|
skipped: 'embedded message (open separately)',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a name that collides with neither an earlier attachment this run
|
||||||
|
// nor a file already present in input/ (user uploads, prior extractions).
|
||||||
|
const name = resolveFreeName(inputDir, baseName, usedNames);
|
||||||
|
usedNames.add(name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = reader.getAttachment(att as never);
|
||||||
|
fs.mkdirSync(inputDir, { recursive: true });
|
||||||
|
const dest = path.join(inputDir, name);
|
||||||
|
fs.writeFileSync(dest, Buffer.from(data.content));
|
||||||
|
attachments.push({
|
||||||
|
fileName: name,
|
||||||
|
contentLength: data.content.length,
|
||||||
|
savedPath: path.join('input', name),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`[ReadMsg] failed to save attachment ${name}: ${(e as Error).message}`);
|
||||||
|
attachments.push({
|
||||||
|
fileName: name,
|
||||||
|
contentLength: att.contentLength,
|
||||||
|
skipped: `extraction failed: ${(e as Error).message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const view: MsgView = {
|
||||||
|
subject: fields.subject,
|
||||||
|
from: (() => {
|
||||||
|
const email = pickEmail(fields.senderSmtpAddress, fields.senderEmail);
|
||||||
|
return fields.senderName || email ? { name: fields.senderName, email } : undefined;
|
||||||
|
})(),
|
||||||
|
to,
|
||||||
|
cc,
|
||||||
|
date: fields.messageDeliveryTime || fields.clientSubmitTime,
|
||||||
|
body: selectMsgBody(fields),
|
||||||
|
attachments,
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = assembleMsgOutput(view, getToolOutputBudgetTokens(ctx), path.basename(resolved));
|
||||||
|
logger.info(
|
||||||
|
`[ReadMsg] ${path.basename(resolved)}: attachments=${attachments.length} bodyFormat=${view.body.format}`,
|
||||||
|
);
|
||||||
|
return { output, isError: false };
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ import { ToolDef } from '../../llm/openai-compat.js';
|
|||||||
import type { ToolContext, ToolResult } from './core.js';
|
import type { ToolContext, ToolResult } from './core.js';
|
||||||
import { resolveAndGuard, resolveOutputPathWithin, truncateToBudget, getToolOutputBudgetTokens } from './core.js';
|
import { resolveAndGuard, resolveOutputPathWithin, truncateToBudget, getToolOutputBudgetTokens } from './core.js';
|
||||||
import { resolveThemePalette, extractSheetStyles } from './excel-styles.js';
|
import { resolveThemePalette, extractSheetStyles } from './excel-styles.js';
|
||||||
|
import { READ_MSG_DEF, executeReadMsg } from './msg.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
import { callVisionModel, resolveImagePath } from './image.js';
|
import { callVisionModel, resolveImagePath } from './image.js';
|
||||||
import type {
|
import type {
|
||||||
@ -351,6 +352,7 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
|
|||||||
ReadDocx: READ_DOCX_DEF,
|
ReadDocx: READ_DOCX_DEF,
|
||||||
ReadPdf: READ_PDF_DEF,
|
ReadPdf: READ_PDF_DEF,
|
||||||
ReadPPTX: READ_PPTX_DEF,
|
ReadPPTX: READ_PPTX_DEF,
|
||||||
|
ReadMsg: READ_MSG_DEF,
|
||||||
SplitExcelSheets: SPLIT_EXCEL_SHEETS_DEF,
|
SplitExcelSheets: SPLIT_EXCEL_SHEETS_DEF,
|
||||||
SplitDocxSections: SPLIT_DOCX_SECTIONS_DEF,
|
SplitDocxSections: SPLIT_DOCX_SECTIONS_DEF,
|
||||||
PdfToImages: PDF_TO_IMAGES_DEF,
|
PdfToImages: PDF_TO_IMAGES_DEF,
|
||||||
@ -2284,6 +2286,8 @@ export async function executeTool(
|
|||||||
return executeReadPdf(input, ctx);
|
return executeReadPdf(input, ctx);
|
||||||
case 'ReadPPTX':
|
case 'ReadPPTX':
|
||||||
return executeReadPptx(input, ctx);
|
return executeReadPptx(input, ctx);
|
||||||
|
case 'ReadMsg':
|
||||||
|
return executeReadMsg(input, ctx);
|
||||||
case 'SplitExcelSheets':
|
case 'SplitExcelSheets':
|
||||||
return executeSplitExcelSheets(input, ctx);
|
return executeSplitExcelSheets(input, ctx);
|
||||||
case 'SplitDocxSections':
|
case 'SplitDocxSections':
|
||||||
|
|||||||
@ -758,3 +758,43 @@ describe('OpenAICompatClient gateway sentinel error events', () => {
|
|||||||
expect(events.at(-1)?.type).toBe('done');
|
expect(events.at(-1)?.type).toBe('done');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('OpenAICompatClient tool-call flush at stream end', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Some OpenAI-compat backends finish a forced/named tool call with
|
||||||
|
// finish_reason 'stop' (or omit it) instead of 'tool_calls'. The client must
|
||||||
|
// still surface the accumulated tool_use at the done boundary, not drop it.
|
||||||
|
it('emits tool_use when the stream ends on finish_reason "stop" (via [DONE])', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeSseResponse([
|
||||||
|
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'do_it', arguments: '{"a":1}' } }] }, finish_reason: null }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
|
||||||
|
'[DONE]',
|
||||||
|
])));
|
||||||
|
const events = await collectEvents(new OpenAICompatClient('http://h:1/v1', 'm'), [{ role: 'user', content: 'q' }]);
|
||||||
|
const toolUse = events.find((e) => e.type === 'tool_use');
|
||||||
|
expect(toolUse).toMatchObject({ type: 'tool_use', name: 'do_it', input: { a: 1 } });
|
||||||
|
// done still follows the tool_use
|
||||||
|
expect(events[events.length - 1].type).toBe('done');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits tool_use when the stream ends at EOF without [DONE]', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeSseResponse([
|
||||||
|
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c2', function: { name: 'fn', arguments: '{}' } }] }, finish_reason: null }] },
|
||||||
|
])));
|
||||||
|
const events = await collectEvents(new OpenAICompatClient('http://h:1/v1', 'm'), [{ role: 'user', content: 'q' }]);
|
||||||
|
expect(events.filter((e) => e.type === 'tool_use')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not double-emit when finish_reason "tool_calls" already flushed', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeSseResponse([
|
||||||
|
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c3', function: { name: 'fn', arguments: '{}' } }] }, finish_reason: 'tool_calls' }] },
|
||||||
|
'[DONE]',
|
||||||
|
])));
|
||||||
|
const events = await collectEvents(new OpenAICompatClient('http://h:1/v1', 'm'), [{ role: 'user', content: 'q' }]);
|
||||||
|
expect(events.filter((e) => e.type === 'tool_use')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { getDefaultProviderRetryConfig, type ProviderRetryConfig } from '../config.js';
|
import { getDefaultProviderRetryConfig, type ProviderRetryConfig } from '../config.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
|
import { recordLlmUsage } from './usage-recorder.js';
|
||||||
import {
|
import {
|
||||||
IMAGE_CONTENT_TOKENS,
|
IMAGE_CONTENT_TOKENS,
|
||||||
estimateMessageTokens,
|
estimateMessageTokens,
|
||||||
@ -238,6 +239,33 @@ interface ToolCallAccumulator {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit accumulated tool calls as `tool_use` events (sorted by index) and
|
||||||
|
* clear the accumulator. Called both on `finish_reason === 'tool_calls'` and
|
||||||
|
* at stream end — some OpenAI-compat backends finish a forced/named tool call
|
||||||
|
* with finish_reason 'stop', so draining at the done boundary keeps the call
|
||||||
|
* from being silently dropped. Returns an empty array when nothing is pending,
|
||||||
|
* so the done-site flush is a no-op for the normal 'tool_calls' path (the map
|
||||||
|
* is already cleared).
|
||||||
|
*/
|
||||||
|
function drainToolCalls(accumulators: Map<number, ToolCallAccumulator>): LLMEvent[] {
|
||||||
|
if (accumulators.size === 0) return [];
|
||||||
|
const events: LLMEvent[] = [];
|
||||||
|
const sortedIndices = Array.from(accumulators.keys()).sort((a, b) => a - b);
|
||||||
|
for (const idx of sortedIndices) {
|
||||||
|
const acc = accumulators.get(idx)!;
|
||||||
|
let input: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
input = JSON.parse(acc.function.arguments) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
logger.warn(`OpenAICompatClient: failed to parse tool arguments: ${acc.function.arguments}`);
|
||||||
|
}
|
||||||
|
events.push({ type: 'tool_use', id: acc.id, name: acc.function.name, input });
|
||||||
|
}
|
||||||
|
accumulators.clear();
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OpenAICompatClientOptions {
|
export interface OpenAICompatClientOptions {
|
||||||
/**
|
/**
|
||||||
* When true, this client treats its endpoint as an LLM gateway / proxy
|
* When true, this client treats its endpoint as an LLM gateway / proxy
|
||||||
@ -252,6 +280,26 @@ export interface OpenAICompatClientOptions {
|
|||||||
proxy?: boolean;
|
proxy?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-call attribution context. Threaded from each call site so the
|
||||||
|
* usage recorder can attribute the completion to a MAESTRO user. Absent
|
||||||
|
* userId falls back to the 'system' sentinel (never NULL).
|
||||||
|
*/
|
||||||
|
export interface LlmCallContext {
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-call request-shaping overrides. Used by callers that need to force a
|
||||||
|
* tool (reflection's forced submit_reflection) or pin sampling temperature.
|
||||||
|
* Kept off the hot agent path (which leaves these unset).
|
||||||
|
*/
|
||||||
|
export interface LlmRequestOptions {
|
||||||
|
temperature?: number;
|
||||||
|
/** OpenAI tool_choice (e.g. `{ type: 'function', function: { name } }`). */
|
||||||
|
toolChoice?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export class OpenAICompatClient {
|
export class OpenAICompatClient {
|
||||||
private retryConfig: ProviderRetryConfig;
|
private retryConfig: ProviderRetryConfig;
|
||||||
readonly timeoutMs: number;
|
readonly timeoutMs: number;
|
||||||
@ -292,7 +340,43 @@ export class OpenAICompatClient {
|
|||||||
this.preferredBackendId = backendId;
|
this.preferredBackendId = backendId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async *chat(messages: Message[], tools?: ToolDef[], externalSignal?: AbortSignal): AsyncGenerator<LLMEvent> {
|
/**
|
||||||
|
* Record one successful completion to the per-user daily usage ledger.
|
||||||
|
* Called from the single done funnel (both the `[DONE]` and EOF exits)
|
||||||
|
* so the two terminal paths can never double-count. `source` is the
|
||||||
|
* client's proxy flag, `model` is the first observed chunk.model (routing
|
||||||
|
* key fallback), `route` is the gateway backendId (proxy) or endpoint host
|
||||||
|
* (direct). Never records on abort / timeout / error (those don't `done`).
|
||||||
|
*/
|
||||||
|
private finalizeDone(
|
||||||
|
usage: { prompt_tokens: number; completion_tokens: number } | undefined,
|
||||||
|
observedModel: string,
|
||||||
|
observedBackendId: string,
|
||||||
|
context?: LlmCallContext,
|
||||||
|
): void {
|
||||||
|
const source: 'gateway' | 'direct' = this.proxy ? 'gateway' : 'direct';
|
||||||
|
const model = observedModel || this.model || 'unknown';
|
||||||
|
let route = 'unknown';
|
||||||
|
if (this.proxy) {
|
||||||
|
route = observedBackendId || 'unknown';
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
route = new URL(this.baseUrl).host || 'unknown';
|
||||||
|
} catch {
|
||||||
|
route = 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recordLlmUsage({
|
||||||
|
userId: context?.userId || 'system',
|
||||||
|
source,
|
||||||
|
model,
|
||||||
|
route,
|
||||||
|
tokensIn: usage?.prompt_tokens ?? 0,
|
||||||
|
tokensOut: usage?.completion_tokens ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async *chat(messages: Message[], tools?: ToolDef[], externalSignal?: AbortSignal, context?: LlmCallContext, requestOptions?: LlmRequestOptions): AsyncGenerator<LLMEvent> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
// アイドルタイムアウト: チャンク受信のたびにリセットされる
|
// アイドルタイムアウト: チャンク受信のたびにリセットされる
|
||||||
let timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
let timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||||
@ -338,6 +422,12 @@ export class OpenAICompatClient {
|
|||||||
if (tools && tools.length > 0) {
|
if (tools && tools.length > 0) {
|
||||||
body['tools'] = tools;
|
body['tools'] = tools;
|
||||||
}
|
}
|
||||||
|
if (requestOptions?.temperature != null) {
|
||||||
|
body['temperature'] = requestOptions.temperature;
|
||||||
|
}
|
||||||
|
if (requestOptions?.toolChoice != null) {
|
||||||
|
body['tool_choice'] = requestOptions.toolChoice;
|
||||||
|
}
|
||||||
// Block oversized prompts before the HTTP request so callers see a
|
// Block oversized prompts before the HTTP request so callers see a
|
||||||
// structured error instead of an opaque HTTP 400. The runtime context
|
// structured error instead of an opaque HTTP 400. The runtime context
|
||||||
// limit is fetched per-model (see fetchOllamaContextLimit) and passed
|
// limit is fetched per-model (see fetchOllamaContextLimit) and passed
|
||||||
@ -358,6 +448,10 @@ export class OpenAICompatClient {
|
|||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
let response: Response | null = null;
|
let response: Response | null = null;
|
||||||
|
// Usage attribution captured during this attempt's stream. Reset
|
||||||
|
// per attempt; only the attempt that reaches `done` records.
|
||||||
|
let observedModel = '';
|
||||||
|
let observedBackendId = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
response = await fetch(`${this.baseUrl}/chat/completions`, {
|
response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||||
@ -440,6 +534,7 @@ export class OpenAICompatClient {
|
|||||||
const rawBackendId = response.headers.get('x-litellm-model-id');
|
const rawBackendId = response.headers.get('x-litellm-model-id');
|
||||||
const backendId = rawBackendId ? rawBackendId.trim() : '';
|
const backendId = rawBackendId ? rawBackendId.trim() : '';
|
||||||
if (backendId.length > 0) {
|
if (backendId.length > 0) {
|
||||||
|
observedBackendId = backendId;
|
||||||
const rawCacheKey = response.headers.get('x-litellm-cache-key');
|
const rawCacheKey = response.headers.get('x-litellm-cache-key');
|
||||||
const cacheKey = rawCacheKey ? rawCacheKey.trim() : '';
|
const cacheKey = rawCacheKey ? rawCacheKey.trim() : '';
|
||||||
yield { type: 'backend', backendId, cacheKey: cacheKey.length > 0 ? cacheKey : null };
|
yield { type: 'backend', backendId, cacheKey: cacheKey.length > 0 ? cacheKey : null };
|
||||||
@ -475,7 +570,13 @@ export class OpenAICompatClient {
|
|||||||
const data = trimmed.slice('data: '.length);
|
const data = trimmed.slice('data: '.length);
|
||||||
|
|
||||||
if (data === '[DONE]') {
|
if (data === '[DONE]') {
|
||||||
|
// Flush any tool calls the backend left un-finished (some
|
||||||
|
// OpenAI-compat servers end a forced/named tool call with
|
||||||
|
// finish_reason 'stop' instead of 'tool_calls'). Without this
|
||||||
|
// the accumulated call would be silently dropped.
|
||||||
|
yield* drainToolCalls(toolCallAccumulators);
|
||||||
// usage 付きで done を emit
|
// usage 付きで done を emit
|
||||||
|
this.finalizeDone(usage, observedModel, observedBackendId, context);
|
||||||
yield { type: 'done', usage };
|
yield { type: 'done', usage };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -488,6 +589,15 @@ export class OpenAICompatClient {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Real model name for usage attribution. The gateway passes
|
||||||
|
// chunks through byte-for-byte, so chunk.model is the actual
|
||||||
|
// backend model for both direct and gateway paths. First
|
||||||
|
// non-empty value wins.
|
||||||
|
if (!observedModel) {
|
||||||
|
const m = chunk['model'];
|
||||||
|
if (typeof m === 'string' && m.length > 0) observedModel = m;
|
||||||
|
}
|
||||||
|
|
||||||
// AAO Gateway / LiteLLM sentinel error event:
|
// AAO Gateway / LiteLLM sentinel error event:
|
||||||
// data: {"error":{"type":"gateway_shutdown","message":"..."}}
|
// data: {"error":{"type":"gateway_shutdown","message":"..."}}
|
||||||
// gateway_shutdown / gateway_timeout は他 worker に retry すれば
|
// gateway_shutdown / gateway_timeout は他 worker に retry すれば
|
||||||
@ -596,23 +706,7 @@ export class OpenAICompatClient {
|
|||||||
|
|
||||||
// tool_calls が完了したら emit
|
// tool_calls が完了したら emit
|
||||||
if (finishReason === 'tool_calls') {
|
if (finishReason === 'tool_calls') {
|
||||||
const sortedIndices = Array.from(toolCallAccumulators.keys()).sort((a, b) => a - b);
|
yield* drainToolCalls(toolCallAccumulators);
|
||||||
for (const idx of sortedIndices) {
|
|
||||||
const acc = toolCallAccumulators.get(idx)!;
|
|
||||||
let input: Record<string, unknown> = {};
|
|
||||||
try {
|
|
||||||
input = JSON.parse(acc.function.arguments) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
logger.warn(`OpenAICompatClient: failed to parse tool arguments: ${acc.function.arguments}`);
|
|
||||||
}
|
|
||||||
yield {
|
|
||||||
type: 'tool_use',
|
|
||||||
id: acc.id,
|
|
||||||
name: acc.function.name,
|
|
||||||
input,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
toolCallAccumulators.clear();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -644,6 +738,8 @@ export class OpenAICompatClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// [DONE] なしにストリームが終了した場合
|
// [DONE] なしにストリームが終了した場合
|
||||||
|
yield* drainToolCalls(toolCallAccumulators);
|
||||||
|
this.finalizeDone(usage, observedModel, observedBackendId, context);
|
||||||
yield { type: 'done', usage };
|
yield { type: 'done', usage };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
52
src/llm/usage-recorder.ts
Normal file
52
src/llm/usage-recorder.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-user LLM usage event, emitted by OpenAICompatClient once per
|
||||||
|
* successful chat completion (gateway-routed and direct alike). The
|
||||||
|
* recorder is a process-global sink so every call site — agent-loop,
|
||||||
|
* title generation, piece classification, reflection — is covered
|
||||||
|
* without threading a Repository handle through every client
|
||||||
|
* construction. This is the single chokepoint the design relies on to
|
||||||
|
* avoid the propagation leaks this codebase has repeatedly hit.
|
||||||
|
*
|
||||||
|
* Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
|
||||||
|
*/
|
||||||
|
export interface LlmUsageEvent {
|
||||||
|
/** Owner id, or 'local' (no-auth) / 'system' (ownerless) sentinel. NOT NULL. */
|
||||||
|
userId: string;
|
||||||
|
source: 'gateway' | 'direct';
|
||||||
|
/** Real model name (chunk.model) with routing-key / 'unknown' fallback. */
|
||||||
|
model: string;
|
||||||
|
/** Backend server name (gateway backendId / direct host) or 'unknown'. */
|
||||||
|
route: string;
|
||||||
|
tokensIn: number;
|
||||||
|
tokensOut: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LlmUsageRecorder = (event: LlmUsageEvent) => void;
|
||||||
|
|
||||||
|
let recorder: LlmUsageRecorder | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install (or clear with null) the process-global usage recorder. Called
|
||||||
|
* once during bootstrap with a thin wrapper over Repository.incrementLlmUsage.
|
||||||
|
*/
|
||||||
|
export function setLlmUsageRecorder(fn: LlmUsageRecorder | null): void {
|
||||||
|
recorder = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record one successful completion. No-op when no recorder is installed
|
||||||
|
* (e.g. unit tests, gateway server process). Writes are best-effort: a DB
|
||||||
|
* hiccup must never kill the agent's stream, so failures are swallowed
|
||||||
|
* with a warn.
|
||||||
|
*/
|
||||||
|
export function recordLlmUsage(event: LlmUsageEvent): void {
|
||||||
|
if (!recorder) return;
|
||||||
|
try {
|
||||||
|
recorder(event);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
logger.warn(`[usage-recorder] record failed (non-fatal): ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/llm/usage-recording.test.ts
Normal file
125
src/llm/usage-recording.test.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Usage recording at the OpenAICompatClient completion boundary.
|
||||||
|
*
|
||||||
|
* Verifies the single finalizeDone funnel:
|
||||||
|
* - a successful stream records exactly one event with model (chunk.model),
|
||||||
|
* tokens from usage, source=direct, route=endpoint host
|
||||||
|
* - a usage-less but successful stream still records (tokens 0)
|
||||||
|
* - an error stream records nothing (abort/timeout/error never `done`)
|
||||||
|
* - proxy mode records source=gateway, route=backendId (x-litellm-model-id)
|
||||||
|
* - no recorder installed = no-op (never throws)
|
||||||
|
*
|
||||||
|
* Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { OpenAICompatClient } from './openai-compat.js';
|
||||||
|
import { getDefaultProviderRetryConfig } from '../config.js';
|
||||||
|
import { setLlmUsageRecorder, type LlmUsageEvent } from './usage-recorder.js';
|
||||||
|
|
||||||
|
const NO_RETRY = { ...getDefaultProviderRetryConfig(), maxAttempts: 1 };
|
||||||
|
|
||||||
|
function sseResponse(chunks: unknown[], headers: Record<string, string> = {}): Response {
|
||||||
|
const lines = chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`);
|
||||||
|
lines.push('data: [DONE]\n\n');
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let i = 0;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: { get: (k: string) => headers[k.toLowerCase()] ?? null },
|
||||||
|
body: {
|
||||||
|
getReader: () => ({
|
||||||
|
read: async () =>
|
||||||
|
i < lines.length
|
||||||
|
? { done: false, value: encoder.encode(lines[i++]) }
|
||||||
|
: { done: true, value: undefined },
|
||||||
|
releaseLock: () => {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textChunk = (model: string) => ({ model, choices: [{ delta: { content: 'hi' }, finish_reason: null }] });
|
||||||
|
const usageChunk = (pin: number, pout: number) => ({ choices: [], usage: { prompt_tokens: pin, completion_tokens: pout } });
|
||||||
|
|
||||||
|
async function drain(client: OpenAICompatClient, ctx?: { userId?: string }): Promise<void> {
|
||||||
|
for await (const _ of client.chat([{ role: 'user', content: 'q' }], undefined, undefined, ctx)) {
|
||||||
|
// discard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setLlmUsageRecorder(null);
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LLM usage recording', () => {
|
||||||
|
it('records one direct event with model, tokens, and endpoint-host route', async () => {
|
||||||
|
const events: LlmUsageEvent[] = [];
|
||||||
|
setLlmUsageRecorder((e) => events.push(e));
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse([textChunk('llama-3.1-8b'), usageChunk(120, 30)])));
|
||||||
|
|
||||||
|
const client = new OpenAICompatClient('http://gpu-1.local:11434/v1', 'role-auto');
|
||||||
|
await drain(client, { userId: 'u1' });
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0]).toEqual({
|
||||||
|
userId: 'u1', source: 'direct', model: 'llama-3.1-8b', route: 'gpu-1.local:11434',
|
||||||
|
tokensIn: 120, tokensOut: 30,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a usage-less success with zero tokens', async () => {
|
||||||
|
const events: LlmUsageEvent[] = [];
|
||||||
|
setLlmUsageRecorder((e) => events.push(e));
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse([textChunk('m')])));
|
||||||
|
|
||||||
|
await drain(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' });
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0]).toMatchObject({ tokensIn: 0, tokensOut: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back userId to system and model to routing key', async () => {
|
||||||
|
const events: LlmUsageEvent[] = [];
|
||||||
|
setLlmUsageRecorder((e) => events.push(e));
|
||||||
|
// No `model` field in the chunk → fall back to the client's routing key.
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse([{ choices: [{ delta: { content: 'x' }, finish_reason: null }] }, usageChunk(1, 1)])));
|
||||||
|
|
||||||
|
await drain(new OpenAICompatClient('http://h:1/v1', 'routing-key')); // no context
|
||||||
|
expect(events[0]).toMatchObject({ userId: 'system', model: 'routing-key' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records nothing on an error stream', async () => {
|
||||||
|
const events: LlmUsageEvent[] = [];
|
||||||
|
setLlmUsageRecorder((e) => events.push(e));
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: false, status: 500, headers: { get: () => null }, text: () => Promise.resolve('boom'),
|
||||||
|
} as unknown as Response));
|
||||||
|
|
||||||
|
await drain(new OpenAICompatClient('http://h:1/v1', 'm', undefined, NO_RETRY), { userId: 'u1' });
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records source=gateway with backendId route in proxy mode', async () => {
|
||||||
|
const events: LlmUsageEvent[] = [];
|
||||||
|
setLlmUsageRecorder((e) => events.push(e));
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
|
||||||
|
sseResponse([textChunk('qwen-72b'), usageChunk(5, 2)], { 'x-litellm-model-id': 'pool-a-node-3' }),
|
||||||
|
));
|
||||||
|
|
||||||
|
const client = new OpenAICompatClient(
|
||||||
|
'http://gateway:4000/v1', 'role-quality', undefined, undefined, undefined, undefined, undefined, undefined,
|
||||||
|
{ proxy: true },
|
||||||
|
);
|
||||||
|
await drain(client, { userId: 'u9' });
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0]).toMatchObject({ source: 'gateway', model: 'qwen-72b', route: 'pool-a-node-3' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when no recorder is installed', async () => {
|
||||||
|
setLlmUsageRecorder(null);
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse([textChunk('m'), usageChunk(1, 1)])));
|
||||||
|
await expect(drain(new OpenAICompatClient('http://h:1/v1', 'm'), { userId: 'u1' })).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -7,6 +7,7 @@ import { normalizeToolNameForMetric, BUILTIN_TOOL_NAMES } from './tool-name-allo
|
|||||||
import { createWorkerMetrics } from './worker-metrics.js';
|
import { createWorkerMetrics } from './worker-metrics.js';
|
||||||
import { TOOL_DEFS as SLIDE_DEFS } from '../engine/tools/slide.js';
|
import { TOOL_DEFS as SLIDE_DEFS } from '../engine/tools/slide.js';
|
||||||
import { TOOL_DEFS as MSLEARN_DEFS } from '../engine/tools/ms-learn.js';
|
import { TOOL_DEFS as MSLEARN_DEFS } from '../engine/tools/ms-learn.js';
|
||||||
|
import { TOOL_DEFS as OFFICE_DEFS } from '../engine/tools/office.js';
|
||||||
|
|
||||||
describe('normalizeToolNameForMetric', () => {
|
describe('normalizeToolNameForMetric', () => {
|
||||||
it('passes built-in tool names through verbatim', () => {
|
it('passes built-in tool names through verbatim', () => {
|
||||||
@ -69,15 +70,19 @@ describe('normalizeToolNameForMetric', () => {
|
|||||||
// so every real call collapsed to 'unknown' in metrics. Pin the allowlist to
|
// so every real call collapsed to 'unknown' in metrics. Pin the allowlist to
|
||||||
// the actual TOOL_DEFS so it can't drift again.
|
// the actual TOOL_DEFS so it can't drift again.
|
||||||
describe('metrics allowlist ↔ real tool definitions (audit regression)', () => {
|
describe('metrics allowlist ↔ real tool definitions (audit regression)', () => {
|
||||||
const realNames = [...Object.keys(SLIDE_DEFS), ...Object.keys(MSLEARN_DEFS)];
|
const realNames = [
|
||||||
|
...Object.keys(SLIDE_DEFS),
|
||||||
|
...Object.keys(MSLEARN_DEFS),
|
||||||
|
...Object.keys(OFFICE_DEFS),
|
||||||
|
];
|
||||||
|
|
||||||
it('every real slide/ms-learn tool normalizes to itself (not "unknown")', () => {
|
it('every real slide/ms-learn/office tool normalizes to itself (not "unknown")', () => {
|
||||||
for (const name of realNames) {
|
for (const name of realNames) {
|
||||||
expect(normalizeToolNameForMetric(name)).toBe(name);
|
expect(normalizeToolNameForMetric(name)).toBe(name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('every real slide/ms-learn tool is present in BUILTIN_TOOL_NAMES', () => {
|
it('every real slide/ms-learn/office tool is present in BUILTIN_TOOL_NAMES', () => {
|
||||||
for (const name of realNames) {
|
for (const name of realNames) {
|
||||||
expect(BUILTIN_TOOL_NAMES.has(name)).toBe(true);
|
expect(BUILTIN_TOOL_NAMES.has(name)).toBe(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,7 +43,7 @@ const BUILTIN_TOOL_NAMES_LIST: ReadonlyArray<string> = [
|
|||||||
// image.ts
|
// image.ts
|
||||||
'AnnotateImage', 'ReadImage',
|
'AnnotateImage', 'ReadImage',
|
||||||
// office.ts
|
// office.ts
|
||||||
'PdfToImages', 'ReadDocx', 'ReadExcel', 'ReadPdf', 'ReadPPTX',
|
'PdfToImages', 'ReadDocx', 'ReadExcel', 'ReadMsg', 'ReadPdf', 'ReadPPTX',
|
||||||
'SplitDocxSections', 'SplitExcelSheets',
|
'SplitDocxSections', 'SplitExcelSheets',
|
||||||
// data.ts
|
// data.ts
|
||||||
'SQLite',
|
'SQLite',
|
||||||
|
|||||||
70
src/net/http-redirect.test.ts
Normal file
70
src/net/http-redirect.test.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import type { Server } from 'http';
|
||||||
|
import * as http from 'http';
|
||||||
|
import { buildRedirectLocation, createHttpRedirectServer } from './http-redirect.js';
|
||||||
|
|
||||||
|
describe('buildRedirectLocation', () => {
|
||||||
|
it('pins the host and preserves path + query', () => {
|
||||||
|
expect(buildRedirectLocation('app.lan', 9876, '/tasks?id=5')).toBe('https://app.lan:9876/tasks?id=5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buildRedirectLocation never echoes a caller-supplied host string', () => {
|
||||||
|
// The function takes pinnedHost as a parameter — it cannot echo an attacker host
|
||||||
|
// because the host is always taken from the first argument, not from reqUrl.
|
||||||
|
// Pass a reqUrl that looks like it contains a host to confirm it is ignored.
|
||||||
|
const loc = buildRedirectLocation('app.lan', 9876, '/');
|
||||||
|
expect(loc).toBe('https://app.lan:9876/');
|
||||||
|
expect(loc).not.toContain('evil.attacker.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips CR/LF/space from the path to prevent header injection', () => {
|
||||||
|
const loc = buildRedirectLocation('app.lan', 9876, '/a\r\nSet-Cookie: x=1 b');
|
||||||
|
expect(loc).not.toMatch(/[\r\n ]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the port for 443', () => {
|
||||||
|
expect(buildRedirectLocation('app.lan', 443, '/')).toBe('https://app.lan/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefixes a leading slash when the url lacks one', () => {
|
||||||
|
expect(buildRedirectLocation('app.lan', 9876, 'x')).toBe('https://app.lan:9876/x');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createHttpRedirectServer', () => {
|
||||||
|
let server: Server;
|
||||||
|
afterEach(() => new Promise<void>((r) => (server ? server.close(() => r()) : r())));
|
||||||
|
|
||||||
|
it('responds 301 with the pinned https Location', async () => {
|
||||||
|
server = createHttpRedirectServer({ httpsPort: 9876, pinnedHost: 'app.lan' });
|
||||||
|
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
|
||||||
|
const addr = server.address();
|
||||||
|
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/x?y=1`, { redirect: 'manual' });
|
||||||
|
expect(res.status).toBe(301);
|
||||||
|
expect(res.headers.get('location')).toBe('https://app.lan:9876/x?y=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores an attacker-controlled Host header (open-redirect guard)', async () => {
|
||||||
|
const srv = createHttpRedirectServer({ httpsPort: 9876, pinnedHost: 'app.lan' });
|
||||||
|
await new Promise<void>((r) => srv.listen(0, '127.0.0.1', r));
|
||||||
|
try {
|
||||||
|
const addr = srv.address();
|
||||||
|
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
||||||
|
// Use node's http.request so we can set the Host header explicitly.
|
||||||
|
// (Some fetch implementations silently drop Host overrides.)
|
||||||
|
const loc = await new Promise<string>((resolve, reject) => {
|
||||||
|
const req = http.request(
|
||||||
|
{ hostname: '127.0.0.1', port, path: '/x', method: 'GET', headers: { Host: 'evil.attacker.com' } },
|
||||||
|
(res) => resolve(res.headers['location'] ?? ''),
|
||||||
|
);
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
expect(loc).toBe('https://app.lan:9876/x');
|
||||||
|
expect(loc).not.toContain('evil.attacker.com');
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((r) => srv.close(() => r()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
21
src/net/http-redirect.ts
Normal file
21
src/net/http-redirect.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { createServer, type Server } from 'http';
|
||||||
|
|
||||||
|
/** Build the redirect target from a PINNED host — never the request Host header. */
|
||||||
|
export function buildRedirectLocation(pinnedHost: string, httpsPort: number, reqUrl: string): string {
|
||||||
|
const safePath = reqUrl.replace(/[\r\n ]/g, '');
|
||||||
|
const hostPort = httpsPort === 443 ? pinnedHost : `${pinnedHost}:${httpsPort}`;
|
||||||
|
return `https://${hostPort}${safePath.startsWith('/') ? safePath : `/${safePath}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedirectServerOpts {
|
||||||
|
httpsPort: number;
|
||||||
|
pinnedHost: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHttpRedirectServer(opts: RedirectServerOpts): Server {
|
||||||
|
return createServer((req, res) => {
|
||||||
|
const location = buildRedirectLocation(opts.pinnedHost, opts.httpsPort, req.url ?? '/');
|
||||||
|
res.writeHead(301, { Location: location });
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
72
src/net/self-signed.test.ts
Normal file
72
src/net/self-signed.test.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync, existsSync, writeFileSync, statSync, readFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { X509Certificate } from 'crypto';
|
||||||
|
import { ensureSelfSignedCert } from './self-signed.js';
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'selfsigned-')); });
|
||||||
|
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
describe('ensureSelfSignedCert', () => {
|
||||||
|
it('generates a cert+key pair with localhost/127.0.0.1 in the SAN', () => {
|
||||||
|
const { cert, key } = ensureSelfSignedCert(dir, []);
|
||||||
|
expect(cert).toContain('BEGIN CERTIFICATE');
|
||||||
|
expect(key).toContain('PRIVATE KEY');
|
||||||
|
const san = new X509Certificate(cert).subjectAltName ?? '';
|
||||||
|
expect(san).toContain('localhost');
|
||||||
|
expect(san).toContain('127.0.0.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes extra SAN hosts', () => {
|
||||||
|
const { cert } = ensureSelfSignedCert(dir, ['maestro.lan']);
|
||||||
|
expect(new X509Certificate(cert).subjectAltName ?? '').toContain('maestro.lan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes a redirect-style hostname in the SAN (guards server.ts augmentation)', () => {
|
||||||
|
// When an operator sets redirect_host: app.lan the server folds that name
|
||||||
|
// into selfSignedHosts before calling resolveTlsOptions — this test guards
|
||||||
|
// that the augmented host is actually present in the emitted certificate.
|
||||||
|
const { cert } = ensureSelfSignedCert(dir, ['redirect.example']);
|
||||||
|
expect(new X509Certificate(cert).subjectAltName ?? '').toContain('redirect.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses an existing valid pair (stable across calls)', () => {
|
||||||
|
const a = ensureSelfSignedCert(dir, []);
|
||||||
|
const b = ensureSelfSignedCert(dir, []);
|
||||||
|
expect(b.cert).toBe(a.cert);
|
||||||
|
expect(b.key).toBe(a.key);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regenerates when the existing pair is mismatched/corrupt', () => {
|
||||||
|
const a = ensureSelfSignedCert(dir, []);
|
||||||
|
writeFileSync(join(dir, 'cert.pem'), 'not a cert');
|
||||||
|
const b = ensureSelfSignedCert(dir, []);
|
||||||
|
expect(b.cert).toContain('BEGIN CERTIFICATE');
|
||||||
|
expect(b.cert).not.toBe(a.cert);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the key file with 0600 permissions', () => {
|
||||||
|
ensureSelfSignedCert(dir, []);
|
||||||
|
expect(statSync(join(dir, 'key.pem')).mode & 0o777).toBe(0o600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the cert file with 0600 permissions', () => {
|
||||||
|
ensureSelfSignedCert(dir, []);
|
||||||
|
expect(statSync(join(dir, 'cert.pem')).mode & 0o777).toBe(0o600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats host:port as a DNS name, not an IP address (regression for isIP fix)', () => {
|
||||||
|
const { cert } = ensureSelfSignedCert(dir, ['myhost:8443']);
|
||||||
|
const san = new X509Certificate(cert).subjectAltName ?? '';
|
||||||
|
// Must appear as a DNS-typed SAN entry, not an IP Address entry
|
||||||
|
expect(san).toContain('DNS:myhost:8443');
|
||||||
|
expect(san).not.toContain('IP Address:myhost:8443');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always includes localhost even when hostname detection is junk', () => {
|
||||||
|
const { cert } = ensureSelfSignedCert(dir, [], { hostname: '' });
|
||||||
|
expect(new X509Certificate(cert).subjectAltName ?? '').toContain('localhost');
|
||||||
|
});
|
||||||
|
});
|
||||||
72
src/net/self-signed.ts
Normal file
72
src/net/self-signed.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { readFileSync, writeFileSync, existsSync, renameSync, mkdirSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { hostname as osHostname } from 'os';
|
||||||
|
import { isIP } from 'net';
|
||||||
|
import { X509Certificate, createPrivateKey } from 'crypto';
|
||||||
|
import { logger } from '../logger.js';
|
||||||
|
import selfsigned from 'selfsigned';
|
||||||
|
|
||||||
|
export interface EnsureCertOpts {
|
||||||
|
/** Override host detection (tests). */
|
||||||
|
hostname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CERT_FILE = 'cert.pem';
|
||||||
|
const KEY_FILE = 'key.pem';
|
||||||
|
|
||||||
|
function sanHosts(extra: string[], hostname: string): string[] {
|
||||||
|
const base = ['localhost', '127.0.0.1', '::1'];
|
||||||
|
const host = hostname.trim();
|
||||||
|
if (host && host !== 'localhost') base.push(host);
|
||||||
|
return [...new Set([...base, ...extra.filter((h) => h.trim().length > 0)])];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIp(value: string): boolean {
|
||||||
|
return isIP(value) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validates that the cert and key match each other; does NOT check expiry by design
|
||||||
|
// (the caller regenerates on restart; the Settings UI surfaces expiry).
|
||||||
|
function readValidPair(dir: string): { cert: string; key: string } | null {
|
||||||
|
const certPath = join(dir, CERT_FILE);
|
||||||
|
const keyPath = join(dir, KEY_FILE);
|
||||||
|
if (!existsSync(certPath) || !existsSync(keyPath)) return null;
|
||||||
|
try {
|
||||||
|
const cert = readFileSync(certPath, 'utf-8');
|
||||||
|
const key = readFileSync(keyPath, 'utf-8');
|
||||||
|
const x509 = new X509Certificate(cert);
|
||||||
|
const priv = createPrivateKey(key);
|
||||||
|
if (!x509.checkPrivateKey(priv)) return null;
|
||||||
|
return { cert, key };
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`[self-signed] existing cert/key unreadable, will regenerate: ${(err as Error).message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureSelfSignedCert(
|
||||||
|
dir: string,
|
||||||
|
extraHosts: string[],
|
||||||
|
opts: EnsureCertOpts = {},
|
||||||
|
): { cert: string; key: string } {
|
||||||
|
const existing = readValidPair(dir);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
const hosts = sanHosts(extraHosts, opts.hostname ?? osHostname());
|
||||||
|
const altNames = hosts.map((h) => (isIp(h) ? { type: 7, ip: h } : { type: 2, value: h }));
|
||||||
|
const pems = selfsigned.generate([{ name: 'commonName', value: hosts[0] }], {
|
||||||
|
keySize: 2048,
|
||||||
|
days: 825,
|
||||||
|
algorithm: 'sha256',
|
||||||
|
extensions: [{ name: 'subjectAltName', altNames }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const certPath = join(dir, CERT_FILE);
|
||||||
|
const keyPath = join(dir, KEY_FILE);
|
||||||
|
writeFileSync(`${certPath}.tmp`, pems.cert, { mode: 0o600 });
|
||||||
|
writeFileSync(`${keyPath}.tmp`, pems.private, { mode: 0o600 });
|
||||||
|
renameSync(`${keyPath}.tmp`, keyPath);
|
||||||
|
renameSync(`${certPath}.tmp`, certPath);
|
||||||
|
return { cert: pems.cert, key: pems.private };
|
||||||
|
}
|
||||||
67
src/net/tls-options.test.ts
Normal file
67
src/net/tls-options.test.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { resolveTlsOptions } from './tls-options.js';
|
||||||
|
import { SERVER_TLS_DEFAULTS } from '../server/config.js';
|
||||||
|
import { ensureSelfSignedCert } from './self-signed.js';
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'tlsopt-')); });
|
||||||
|
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
describe('resolveTlsOptions', () => {
|
||||||
|
it('returns a self-signed pair when no cert_file is configured', () => {
|
||||||
|
const opts = resolveTlsOptions({ ...SERVER_TLS_DEFAULTS, enabled: true, selfSignedDir: dir });
|
||||||
|
expect(opts.cert).toContain('BEGIN CERTIFICATE');
|
||||||
|
expect(opts.key).toContain('PRIVATE KEY');
|
||||||
|
expect(opts.minVersion).toBe('TLSv1.2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses provided PEM files when cert_file and key_file are set', () => {
|
||||||
|
const { cert, key } = ensureSelfSignedCert(dir, []);
|
||||||
|
writeFileSync(join(dir, 'my.crt'), cert);
|
||||||
|
writeFileSync(join(dir, 'my.key'), key);
|
||||||
|
const opts = resolveTlsOptions({
|
||||||
|
...SERVER_TLS_DEFAULTS, enabled: true,
|
||||||
|
certFile: join(dir, 'my.crt'), keyFile: join(dir, 'my.key'),
|
||||||
|
});
|
||||||
|
expect(opts.cert).toBe(cert);
|
||||||
|
expect(opts.key).toBe(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed (throws) when cert_file is unreadable — never self-signs', () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveTlsOptions({
|
||||||
|
...SERVER_TLS_DEFAULTS, enabled: true,
|
||||||
|
certFile: join(dir, 'missing.crt'), keyFile: join(dir, 'missing.key'),
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when the provided cert is malformed PEM', () => {
|
||||||
|
writeFileSync(join(dir, 'bad.crt'), 'garbage');
|
||||||
|
writeFileSync(join(dir, 'bad.key'), 'garbage');
|
||||||
|
expect(() =>
|
||||||
|
resolveTlsOptions({
|
||||||
|
...SERVER_TLS_DEFAULTS, enabled: true,
|
||||||
|
certFile: join(dir, 'bad.crt'), keyFile: join(dir, 'bad.key'),
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed when provided cert and key do not match', () => {
|
||||||
|
const a = ensureSelfSignedCert(dir, []);
|
||||||
|
const dir2 = mkdtempSync(join(tmpdir(), 'tlsopt2-'));
|
||||||
|
const b = ensureSelfSignedCert(dir2, ['other']);
|
||||||
|
writeFileSync(join(dir, 'a.crt'), a.cert);
|
||||||
|
writeFileSync(join(dir, 'b.key'), b.key);
|
||||||
|
expect(() =>
|
||||||
|
resolveTlsOptions({
|
||||||
|
...SERVER_TLS_DEFAULTS, enabled: true,
|
||||||
|
certFile: join(dir, 'a.crt'), keyFile: join(dir, 'b.key'),
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
rmSync(dir2, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
30
src/net/tls-options.ts
Normal file
30
src/net/tls-options.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { X509Certificate, createPrivateKey } from 'crypto';
|
||||||
|
import type { ServerTlsConfig } from '../server/config.js';
|
||||||
|
import { ensureSelfSignedCert } from './self-signed.js';
|
||||||
|
|
||||||
|
export interface ResolvedTlsOptions {
|
||||||
|
cert: string;
|
||||||
|
key: string;
|
||||||
|
minVersion: 'TLSv1.2' | 'TLSv1.3';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve TLS material. Any failure on an operator-provided cert is FATAL
|
||||||
|
* (throws) — never silently fall back to self-signed or HTTP, which would
|
||||||
|
* downgrade an operator who deliberately configured a real certificate.
|
||||||
|
*/
|
||||||
|
export function resolveTlsOptions(tls: ServerTlsConfig): ResolvedTlsOptions {
|
||||||
|
if (tls.certFile && tls.keyFile) {
|
||||||
|
const cert = readFileSync(tls.certFile, 'utf-8');
|
||||||
|
const key = readFileSync(tls.keyFile, 'utf-8');
|
||||||
|
const x509 = new X509Certificate(cert);
|
||||||
|
const priv = createPrivateKey(key);
|
||||||
|
if (!x509.checkPrivateKey(priv)) {
|
||||||
|
throw new Error('server.tls: cert_file and key_file do not match');
|
||||||
|
}
|
||||||
|
return { cert, key, minVersion: tls.minVersion };
|
||||||
|
}
|
||||||
|
const { cert, key } = ensureSelfSignedCert(tls.selfSignedDir, tls.selfSignedHosts);
|
||||||
|
return { cert, key, minVersion: tls.minVersion };
|
||||||
|
}
|
||||||
61
src/server/config.test.ts
Normal file
61
src/server/config.test.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { mergeServerConfig, SERVER_TLS_DEFAULTS } from './config.js';
|
||||||
|
|
||||||
|
describe('mergeServerConfig', () => {
|
||||||
|
it('upgrade-safe: an absent server block disables TLS', () => {
|
||||||
|
const cfg = mergeServerConfig(undefined, { freshInstall: false });
|
||||||
|
expect(cfg.tls.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fresh install with absent block enables TLS', () => {
|
||||||
|
const cfg = mergeServerConfig(undefined, { freshInstall: true });
|
||||||
|
expect(cfg.tls.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an explicit enabled value always wins over the freshInstall default', () => {
|
||||||
|
expect(mergeServerConfig({ tls: { enabled: false } }, { freshInstall: true }).tls.enabled).toBe(false);
|
||||||
|
expect(mergeServerConfig({ tls: { enabled: true } }, { freshInstall: false }).tls.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills defaults for unspecified tls fields', () => {
|
||||||
|
const cfg = mergeServerConfig({ tls: { enabled: true } }, { freshInstall: false });
|
||||||
|
expect(cfg.tls.minVersion).toBe(SERVER_TLS_DEFAULTS.minVersion);
|
||||||
|
expect(cfg.tls.selfSignedDir).toBe(SERVER_TLS_DEFAULTS.selfSignedDir);
|
||||||
|
expect(cfg.tls.httpRedirect).toBe(true);
|
||||||
|
expect(cfg.tls.httpRedirectPort).toBe(9080);
|
||||||
|
expect(cfg.tls.selfSignedHosts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when http_redirect_port equals the https port', () => {
|
||||||
|
expect(() =>
|
||||||
|
mergeServerConfig({ tls: { enabled: true, httpRedirectPort: 9876 } }, { freshInstall: false, httpsPort: 9876 }),
|
||||||
|
).toThrow(/redirect.*port/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when only one of cert_file/key_file is set', () => {
|
||||||
|
expect(() =>
|
||||||
|
mergeServerConfig({ tls: { enabled: true, certFile: '/x/cert.pem' } }, { freshInstall: false }),
|
||||||
|
).toThrow(/cert_file.*key_file|both/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when key_file is set without cert_file', () => {
|
||||||
|
expect(() =>
|
||||||
|
mergeServerConfig({ tls: { enabled: true, keyFile: '/x/key.pem' } }, { freshInstall: false }),
|
||||||
|
).toThrow(/cert_file.*key_file|both/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw on port collision when httpRedirect is false', () => {
|
||||||
|
expect(() =>
|
||||||
|
mergeServerConfig(
|
||||||
|
{ tls: { enabled: true, httpRedirect: false, httpRedirectPort: 9876 } },
|
||||||
|
{ freshInstall: false, httpsPort: 9876 },
|
||||||
|
),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disabled TLS with a lone certFile does not throw', () => {
|
||||||
|
expect(() =>
|
||||||
|
mergeServerConfig({ tls: { enabled: false, certFile: '/x/c.pem' } }, { freshInstall: false }),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
63
src/server/config.ts
Normal file
63
src/server/config.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
export interface ServerTlsConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
certFile: string | null;
|
||||||
|
keyFile: string | null;
|
||||||
|
minVersion: 'TLSv1.2' | 'TLSv1.3';
|
||||||
|
selfSignedDir: string;
|
||||||
|
selfSignedHosts: string[];
|
||||||
|
httpRedirect: boolean;
|
||||||
|
httpRedirectPort: number;
|
||||||
|
/**
|
||||||
|
* Host used to build the HTTP→HTTPS redirect `Location` header.
|
||||||
|
* When null the bind host is used instead.
|
||||||
|
* Intentionally NOT taken from the request Host header to prevent open-redirect attacks.
|
||||||
|
*/
|
||||||
|
redirectHost: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerConfig {
|
||||||
|
tls: ServerTlsConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SERVER_TLS_DEFAULTS: ServerTlsConfig = {
|
||||||
|
enabled: false,
|
||||||
|
certFile: null,
|
||||||
|
keyFile: null,
|
||||||
|
minVersion: 'TLSv1.2',
|
||||||
|
selfSignedDir: './data/tls',
|
||||||
|
selfSignedHosts: [],
|
||||||
|
httpRedirect: true,
|
||||||
|
httpRedirectPort: 9080,
|
||||||
|
redirectHost: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MergeServerOpts {
|
||||||
|
freshInstall: boolean;
|
||||||
|
httpsPort?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeServerConfig(
|
||||||
|
partial: Partial<ServerConfig> | undefined,
|
||||||
|
opts: MergeServerOpts,
|
||||||
|
): ServerConfig {
|
||||||
|
const tlsPartial = (partial?.tls ?? {}) as Partial<ServerTlsConfig>;
|
||||||
|
const enabledDefault = opts.freshInstall;
|
||||||
|
const tls: ServerTlsConfig = {
|
||||||
|
...SERVER_TLS_DEFAULTS,
|
||||||
|
...tlsPartial,
|
||||||
|
enabled: tlsPartial.enabled ?? enabledDefault,
|
||||||
|
};
|
||||||
|
tls.selfSignedHosts = [...(tlsPartial.selfSignedHosts ?? SERVER_TLS_DEFAULTS.selfSignedHosts)];
|
||||||
|
|
||||||
|
if (tls.enabled) {
|
||||||
|
const hasCert = !!tls.certFile;
|
||||||
|
const hasKey = !!tls.keyFile;
|
||||||
|
if (hasCert !== hasKey) {
|
||||||
|
throw new Error('server.tls: set both cert_file and key_file, or neither');
|
||||||
|
}
|
||||||
|
if (opts.httpsPort != null && tls.httpRedirect && tls.httpRedirectPort === opts.httpsPort) {
|
||||||
|
throw new Error(`server.tls: http_redirect_port (${tls.httpRedirectPort}) must differ from the HTTPS port`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { tls };
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
buildTitleFallback,
|
buildTitleFallback,
|
||||||
|
buildTitleFromGoal,
|
||||||
buildTitlePrompt,
|
buildTitlePrompt,
|
||||||
isUrlOnlyTitleInput,
|
isUrlOnlyTitleInput,
|
||||||
stripUrlsForTitle,
|
stripUrlsForTitle,
|
||||||
@ -27,3 +28,29 @@ describe('title generation helpers', () => {
|
|||||||
expect(buildTitlePrompt(input)).not.toContain('example.com');
|
expect(buildTitlePrompt(input)).not.toContain('example.com');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('buildTitleFromGoal', () => {
|
||||||
|
it('uses the first non-empty line of the goal', () => {
|
||||||
|
expect(buildTitleFromGoal('議事録を作成する\n\n詳細な背景説明...')).toBe('議事録を作成する');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips markdown heading and list markers', () => {
|
||||||
|
expect(buildTitleFromGoal('## 売上レポートをまとめる')).toBe('売上レポートをまとめる');
|
||||||
|
expect(buildTitleFromGoal('- 顧客リストを整理する')).toBe('顧客リストを整理する');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps length at 40 chars', () => {
|
||||||
|
const long = 'あ'.repeat(80);
|
||||||
|
expect(buildTitleFromGoal(long).length).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('masks URLs and returns empty for URL-only goals', () => {
|
||||||
|
expect(buildTitleFromGoal('https://example.com/x')).toBe('');
|
||||||
|
expect(buildTitleFromGoal('要約して https://example.com/x')).toBe('要約して [URL]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for blank goal so callers skip the update', () => {
|
||||||
|
expect(buildTitleFromGoal('')).toBe('');
|
||||||
|
expect(buildTitleFromGoal(' \n ')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -15,6 +15,23 @@ export function buildTitleFallback(input: string): string {
|
|||||||
return stripUrlsForTitle(input).slice(0, 40).trim() || '新しい依頼';
|
return stripUrlsForTitle(input).slice(0, 40).trim() || '新しい依頼';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a concise task title from the Mission Brief `goal` set by the agent.
|
||||||
|
* Used at runtime (no LLM call): the agent already restates the user's
|
||||||
|
* requirement as `goal` via MissionUpdate, so we take its first non-empty
|
||||||
|
* line, strip markdown list/heading markers and URLs, and cap the length.
|
||||||
|
* Returns '' when nothing usable remains so callers can skip the update.
|
||||||
|
*/
|
||||||
|
export function buildTitleFromGoal(goal: string): string {
|
||||||
|
const firstLine = goal
|
||||||
|
.split('\n')
|
||||||
|
.map(l => l.trim())
|
||||||
|
.find(l => l.length > 0) ?? '';
|
||||||
|
const cleaned = firstLine.replace(/^#{1,6}\s*/, '').replace(/^[-*+]\s+/, '');
|
||||||
|
if (isUrlOnlyTitleInput(cleaned)) return '';
|
||||||
|
return stripUrlsForTitle(cleaned).slice(0, 40).trim();
|
||||||
|
}
|
||||||
|
|
||||||
export function buildTitlePrompt(input: string): string | null {
|
export function buildTitlePrompt(input: string): string | null {
|
||||||
if (isUrlOnlyTitleInput(input)) return null;
|
if (isUrlOnlyTitleInput(input)) return null;
|
||||||
const sanitized = stripUrlsForTitle(input).slice(0, 500);
|
const sanitized = stripUrlsForTitle(input).slice(0, 500);
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { logger } from './logger.js';
|
|||||||
import { accessSync, existsSync, mkdirSync, constants } from 'fs';
|
import { accessSync, existsSync, mkdirSync, constants } from 'fs';
|
||||||
import { dirname, resolve, join } from 'path';
|
import { dirname, resolve, join } from 'path';
|
||||||
import { OpenAICompatClient } from './llm/openai-compat.js';
|
import { OpenAICompatClient } from './llm/openai-compat.js';
|
||||||
|
import { setLlmUsageRecorder } from './llm/usage-recorder.js';
|
||||||
import { llmRoutingKey } from './llm/routing-key.js';
|
import { llmRoutingKey } from './llm/routing-key.js';
|
||||||
import { ConfigManager } from './config-manager.js';
|
import { ConfigManager } from './config-manager.js';
|
||||||
import { WorkerManager } from './worker-manager.js';
|
import { WorkerManager } from './worker-manager.js';
|
||||||
@ -121,6 +122,23 @@ export async function start(opts: StartWorkerOptions = {}): Promise<void> {
|
|||||||
const repo = new Repository(dbPath);
|
const repo = new Repository(dbPath);
|
||||||
runMigrations(repo.getDb());
|
runMigrations(repo.getDb());
|
||||||
|
|
||||||
|
// Install the process-global LLM usage recorder so every OpenAICompatClient
|
||||||
|
// completion (agent loop, title, classify, reflection — gateway + direct)
|
||||||
|
// lands in the per-user daily ledger. Best-effort: the recorder helper
|
||||||
|
// swallows write errors so a DB hiccup never kills an agent stream.
|
||||||
|
// Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
|
||||||
|
setLlmUsageRecorder((event) => {
|
||||||
|
repo.incrementLlmUsage({
|
||||||
|
userId: event.userId,
|
||||||
|
source: event.source,
|
||||||
|
model: event.model,
|
||||||
|
route: event.route,
|
||||||
|
tokensIn: event.tokensIn,
|
||||||
|
tokensOut: event.tokensOut,
|
||||||
|
requests: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 起動時に孤立ジョブを回復
|
// 起動時に孤立ジョブを回復
|
||||||
await repo.recoverOrphanedJobs();
|
await repo.recoverOrphanedJobs();
|
||||||
|
|
||||||
@ -135,7 +153,7 @@ export async function start(opts: StartWorkerOptions = {}): Promise<void> {
|
|||||||
config.provider.workers.find(w => w.enabled !== false && w.roles?.includes('title')) ??
|
config.provider.workers.find(w => w.enabled !== false && w.roles?.includes('title')) ??
|
||||||
config.provider.workers[0];
|
config.provider.workers[0];
|
||||||
let titleClient: OpenAICompatClient | null = null;
|
let titleClient: OpenAICompatClient | null = null;
|
||||||
let generateTitle: ((body: string) => Promise<string>) | undefined;
|
let generateTitle: ((body: string, ownerId?: string) => Promise<string>) | undefined;
|
||||||
|
|
||||||
if (titleWorker) {
|
if (titleWorker) {
|
||||||
const titleModel = titleWorker.model ?? config.provider.model;
|
const titleModel = titleWorker.model ?? config.provider.model;
|
||||||
@ -156,18 +174,24 @@ export async function start(opts: StartWorkerOptions = {}): Promise<void> {
|
|||||||
titleClient = new OpenAICompatClient(
|
titleClient = new OpenAICompatClient(
|
||||||
titleWorker.endpoint,
|
titleWorker.endpoint,
|
||||||
titleRoutingKey,
|
titleRoutingKey,
|
||||||
undefined,
|
titleWorker.apiKey,
|
||||||
config.provider.retry,
|
config.provider.retry,
|
||||||
(config.provider.timeoutMinutes ?? 10) * 60 * 1000,
|
(config.provider.timeoutMinutes ?? 10) * 60 * 1000,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
// proxy mode so title/classification usage is recorded as
|
||||||
|
// source='gateway' with the backendId route (not mislabeled 'direct').
|
||||||
|
{ proxy: titleWorker.proxy === true },
|
||||||
);
|
);
|
||||||
|
|
||||||
generateTitle = async (body: string): Promise<string> => {
|
generateTitle = async (body: string, ownerId?: string): Promise<string> => {
|
||||||
const fallback = buildTitleFallback(body);
|
const fallback = buildTitleFallback(body);
|
||||||
const prompt = buildTitlePrompt(body);
|
const prompt = buildTitlePrompt(body);
|
||||||
if (!prompt) return fallback;
|
if (!prompt) return fallback;
|
||||||
let title = '';
|
let title = '';
|
||||||
try {
|
try {
|
||||||
for await (const event of titleClient!.chat([{ role: 'user', content: prompt }])) {
|
for await (const event of titleClient!.chat([{ role: 'user', content: prompt }], undefined, undefined, { userId: ownerId })) {
|
||||||
if (event.type === 'text') title += event.text;
|
if (event.type === 'text') title += event.text;
|
||||||
if (event.type === 'error') return fallback;
|
if (event.type === 'error') return fallback;
|
||||||
if (event.type === 'done') break;
|
if (event.type === 'done') break;
|
||||||
@ -232,7 +256,7 @@ export async function start(opts: StartWorkerOptions = {}): Promise<void> {
|
|||||||
const selectPiece = titleClient
|
const selectPiece = titleClient
|
||||||
? async (body: string, fileNames: string[], userId?: string): Promise<string> => {
|
? async (body: string, fileNames: string[], userId?: string): Promise<string> => {
|
||||||
const pieces = pieceCatalog.getForUser(userId ?? 'local');
|
const pieces = pieceCatalog.getForUser(userId ?? 'local');
|
||||||
const result = await classifyPiece(titleClient!, body, pieces, fileNames);
|
const result = await classifyPiece(titleClient!, body, pieces, fileNames, undefined, userId ?? 'local');
|
||||||
return result ?? 'chat';
|
return result ?? 'chat';
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@ -818,7 +818,7 @@ export class Worker {
|
|||||||
];
|
];
|
||||||
|
|
||||||
let answer = '';
|
let answer = '';
|
||||||
for await (const event of llmClient.chat(messages)) {
|
for await (const event of llmClient.chat(messages, undefined, undefined, { userId: parentJob?.ownerId ?? 'local' })) {
|
||||||
if (event.type === 'text') {
|
if (event.type === 'text') {
|
||||||
answer += event.text;
|
answer += event.text;
|
||||||
} else if (event.type === 'error') {
|
} else if (event.type === 'error') {
|
||||||
@ -1779,6 +1779,8 @@ export class Worker {
|
|||||||
// Same credential as normal task LLM calls — a key-enforcing
|
// Same credential as normal task LLM calls — a key-enforcing
|
||||||
// gateway 401s reflection without it.
|
// gateway 401s reflection without it.
|
||||||
llmApiKey: this.getWorkerDef().apiKey,
|
llmApiKey: this.getWorkerDef().apiKey,
|
||||||
|
llmProxy: this.getWorkerDef().proxy === true,
|
||||||
|
llmContextLimitTokens: this.contextLimitTokens,
|
||||||
},
|
},
|
||||||
job
|
job
|
||||||
);
|
);
|
||||||
|
|||||||
@ -21,7 +21,7 @@ import { useBackdropClose } from './lib/useBackdropClose';
|
|||||||
import { TopBar } from './components/layout/TopBar';
|
import { TopBar } from './components/layout/TopBar';
|
||||||
import { NavDrawer } from './components/layout/NavDrawer';
|
import { NavDrawer } from './components/layout/NavDrawer';
|
||||||
import { useEdgeSwipe } from './hooks/useEdgeSwipe';
|
import { useEdgeSwipe } from './hooks/useEdgeSwipe';
|
||||||
import { visibleNavItemsFor, useCompactNav } from './components/layout/TopBar';
|
import { visibleNavItemsFor } from './components/layout/TopBar';
|
||||||
import { ResizeHandle } from './components/layout/ResizeHandle';
|
import { ResizeHandle } from './components/layout/ResizeHandle';
|
||||||
import { TaskListPanel } from './components/list/TaskListPanel';
|
import { TaskListPanel } from './components/list/TaskListPanel';
|
||||||
import { ChatPane } from './components/chat/ChatPane';
|
import { ChatPane } from './components/chat/ChatPane';
|
||||||
@ -40,6 +40,7 @@ import { UsersPage } from './pages/UsersPage';
|
|||||||
import { AdminCaptchaPage } from './pages/AdminCaptchaPage';
|
import { AdminCaptchaPage } from './pages/AdminCaptchaPage';
|
||||||
import { SharedView } from './pages/SharedView';
|
import { SharedView } from './pages/SharedView';
|
||||||
import { UserFolderTab } from './components/userfolder/UserFolderTab';
|
import { UserFolderTab } from './components/userfolder/UserFolderTab';
|
||||||
|
import { UsagePage } from './components/usage/UsagePage';
|
||||||
import { HelpPage } from './pages/HelpPage';
|
import { HelpPage } from './pages/HelpPage';
|
||||||
import { TaskListWithSidePanel } from './components/dashboard/TaskListWithSidePanel';
|
import { TaskListWithSidePanel } from './components/dashboard/TaskListWithSidePanel';
|
||||||
import type { ConsoleStatus } from './lib/ssh-console-types';
|
import type { ConsoleStatus } from './lib/ssh-console-types';
|
||||||
@ -163,7 +164,9 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
const tabletDetailBackdrop = useBackdropClose(() => setTabletDetailOpen(false));
|
const tabletDetailBackdrop = useBackdropClose(() => setTabletDetailOpen(false));
|
||||||
const [navDrawerOpen, setNavDrawerOpen] = useState(false);
|
const [navDrawerOpen, setNavDrawerOpen] = useState(false);
|
||||||
const hamburgerRef = useRef<HTMLButtonElement>(null);
|
const hamburgerRef = useRef<HTMLButtonElement>(null);
|
||||||
const compactMode = useCompactNav(isAdmin, authEnabled);
|
// compactMode is measured by TopBar (actual fit) and reported up here so the
|
||||||
|
// nav drawer / edge-swipe stay in sync with whether the hamburger is shown.
|
||||||
|
const [compactMode, setCompactMode] = useState(false);
|
||||||
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
|
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
|
||||||
|
|
||||||
const openNavDrawer = () => {
|
const openNavDrawer = () => {
|
||||||
@ -355,10 +358,10 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
sortMode: sort,
|
sortMode: sort,
|
||||||
searchQuery: search,
|
searchQuery: search,
|
||||||
activeTaskId: localTaskId,
|
activeTaskId: localTaskId,
|
||||||
// Owner scope (自分/すべて). Only active when auth is on — in no-auth mode
|
// Owner scope (自分/他のユーザ). Only active when auth is on — in no-auth mode
|
||||||
// every task is owned by 'local' and the toggle would be meaningless.
|
// every task is owned by 'local' and the toggle would be meaningless.
|
||||||
scope: urlState.scope,
|
scope: urlState.scope,
|
||||||
onScopeChange: (scope: 'mine' | 'all') => setUrlState(prev => ({ ...prev, scope })),
|
onScopeChange: (scope: 'mine' | 'others') => setUrlState(prev => ({ ...prev, scope })),
|
||||||
currentUserId: user?.id ?? null,
|
currentUserId: user?.id ?? null,
|
||||||
scopeEnabled: authEnabled && !!user,
|
scopeEnabled: authEnabled && !!user,
|
||||||
onStatusChange: (s: string) => setUrlState(prev => ({ ...prev, status: s as typeof status })),
|
onStatusChange: (s: string) => setUrlState(prev => ({ ...prev, status: s as typeof status })),
|
||||||
@ -451,6 +454,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
hamburgerButtonRef={hamburgerRef}
|
hamburgerButtonRef={hamburgerRef}
|
||||||
navDrawerOpen={navDrawerOpen}
|
navDrawerOpen={navDrawerOpen}
|
||||||
onOpenCommandK={() => setCmdkOpen(true)}
|
onOpenCommandK={() => setCmdkOpen(true)}
|
||||||
|
onCompactChange={setCompactMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div role="status" aria-live="polite" aria-atomic="true" className="flex-shrink-0">
|
<div role="status" aria-live="polite" aria-atomic="true" className="flex-shrink-0">
|
||||||
@ -471,6 +475,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
{page === 'users' && isAdmin && authEnabled && <div className="flex-1 min-h-0 overflow-hidden"><UsersPage /></div>}
|
{page === 'users' && isAdmin && authEnabled && <div className="flex-1 min-h-0 overflow-hidden"><UsersPage /></div>}
|
||||||
{page === 'captcha' && <div className="flex-1 min-h-0 overflow-hidden"><AdminCaptchaPage isAdmin={isAdmin} /></div>}
|
{page === 'captcha' && <div className="flex-1 min-h-0 overflow-hidden"><AdminCaptchaPage isAdmin={isAdmin} /></div>}
|
||||||
{page === 'userfolder' && <div className="flex-1 min-h-0 overflow-hidden"><UserFolderTab showToast={showToast} /></div>}
|
{page === 'userfolder' && <div className="flex-1 min-h-0 overflow-hidden"><UserFolderTab showToast={showToast} /></div>}
|
||||||
|
{page === 'usage' && <div className="flex-1 min-h-0 overflow-hidden flex flex-col"><UsagePage /></div>}
|
||||||
{page === 'help' && <div className="flex-1 min-h-0 overflow-hidden"><HelpPage isAdmin={isAdmin} onAskAi={() => { setCreateInitialPiece('help'); setShowCreateDialog(true); }} selectedId={urlState.help} onSelect={(id) => setUrlState(prev => ({ ...prev, help: id }))} /></div>}
|
{page === 'help' && <div className="flex-1 min-h-0 overflow-hidden"><HelpPage isAdmin={isAdmin} onAskAi={() => { setCreateInitialPiece('help'); setShowCreateDialog(true); }} selectedId={urlState.help} onSelect={(id) => setUrlState(prev => ({ ...prev, help: id }))} /></div>}
|
||||||
|
|
||||||
{page === 'tasks' && <OutputPreviewProvider openOutputPath={handleOutputPathLinkClick}><div className="flex-1 min-h-0 overflow-hidden">
|
{page === 'tasks' && <OutputPreviewProvider openOutputPath={handleOutputPathLinkClick}><div className="flex-1 min-h-0 overflow-hidden">
|
||||||
@ -563,8 +568,10 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* タブレット: 2カラム (sm 〜 lg) */}
|
{/* タブレット: 2カラム (sm 〜 xl)。3列デスクトップは横幅が足りないと詳細列が
|
||||||
<div className="hidden sm:grid lg:hidden gap-2 p-2 h-full" style={{ gridTemplateColumns: 'clamp(220px, 30vw, 280px) minmax(0, 1fr)' }}>
|
狭まりタブが折り返すため、切替を xl(1280px) まで上げて中間帯はこの
|
||||||
|
2列+詳細オーバーレイで運用する。 */}
|
||||||
|
<div className="hidden sm:grid xl:hidden gap-2 p-2 h-full" style={{ gridTemplateColumns: 'clamp(220px, 30vw, 280px) minmax(0, 1fr)' }}>
|
||||||
<div className="bg-canvas border border-hairline rounded-md overflow-hidden">
|
<div className="bg-canvas border border-hairline rounded-md overflow-hidden">
|
||||||
<TaskListWithSidePanel
|
<TaskListWithSidePanel
|
||||||
upper={<div className="h-full overflow-hidden p-3"><TaskListPanel {...taskListProps} /></div>}
|
upper={<div className="h-full overflow-hidden p-3"><TaskListPanel {...taskListProps} /></div>}
|
||||||
@ -583,9 +590,9 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* デスクトップ: >= lg (1024px). normal=3 列、focused=rail/chat/handle/ws=4 列 */}
|
{/* デスクトップ: >= xl (1280px). normal=3 列、focused=rail/chat/handle/ws=4 列 */}
|
||||||
<div
|
<div
|
||||||
className="hidden lg:grid gap-2 p-2 h-full"
|
className="hidden xl:grid gap-2 p-2 h-full"
|
||||||
data-focused-grid={isFocused ? '1' : undefined}
|
data-focused-grid={isFocused ? '1' : undefined}
|
||||||
style={gridStyle}
|
style={gridStyle}
|
||||||
>
|
>
|
||||||
@ -642,7 +649,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
|
|
||||||
{/* Tablet: detail overlay */}
|
{/* Tablet: detail overlay */}
|
||||||
{tabletDetailOpen && panelOpen && (
|
{tabletDetailOpen && panelOpen && (
|
||||||
<div className="hidden sm:block lg:hidden fixed inset-0 bg-black/40 z-40" {...tabletDetailBackdrop}>
|
<div className="hidden sm:block xl:hidden fixed inset-0 bg-black/40 z-40" {...tabletDetailBackdrop}>
|
||||||
<div className="absolute right-0 top-0 bottom-0 bg-canvas shadow-2xl flex flex-col overflow-hidden" style={{ width: 'min(480px, 90vw)' }} onClick={e => e.stopPropagation()}>
|
<div className="absolute right-0 top-0 bottom-0 bg-canvas shadow-2xl flex flex-col overflow-hidden" style={{ width: 'min(480px, 90vw)' }} onClick={e => e.stopPropagation()}>
|
||||||
{localTaskId && (
|
{localTaskId && (
|
||||||
<LocalDetailPanel
|
<LocalDetailPanel
|
||||||
@ -675,6 +682,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
|
|||||||
section={previewState.section}
|
section={previewState.section}
|
||||||
filePath={previewState.filePath}
|
filePath={previewState.filePath}
|
||||||
editable={previewState.editable}
|
editable={previewState.editable}
|
||||||
|
trustedHtmlUrl={user || !authEnabled ? previewState.trustedHtmlUrl : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{branding.footerText && (
|
{branding.footerText && (
|
||||||
|
|||||||
@ -99,9 +99,13 @@ export interface SubtaskActivity {
|
|||||||
activityLog: string;
|
activityLog: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TitleSource = 'auto' | 'agent' | 'user';
|
||||||
|
|
||||||
export interface LocalTask {
|
export interface LocalTask {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
/** Provenance of the title: 'auto' (creation fallback), 'agent' (derived from goal), 'user' (manual edit). */
|
||||||
|
titleSource?: TitleSource;
|
||||||
body: string;
|
body: string;
|
||||||
pieceName: string;
|
pieceName: string;
|
||||||
profile: string;
|
profile: string;
|
||||||
@ -255,7 +259,7 @@ export async function postLocalTaskComment(taskId: number, body: string, author:
|
|||||||
|
|
||||||
export async function updateLocalTask(
|
export async function updateLocalTask(
|
||||||
taskId: number,
|
taskId: number,
|
||||||
updates: { visibility?: Visibility; visibilityScopeOrgId?: string | null },
|
updates: { title?: string; visibility?: Visibility; visibilityScopeOrgId?: string | null },
|
||||||
): Promise<LocalTask> {
|
): Promise<LocalTask> {
|
||||||
const res = await fetch(`${BASE}/local/tasks/${taskId}`, {
|
const res = await fetch(`${BASE}/local/tasks/${taskId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@ -267,6 +271,14 @@ export async function updateLocalTask(
|
|||||||
return data.task;
|
return data.task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Trigger on-demand AI title regeneration. Owner/admin only. Returns the new title. */
|
||||||
|
export async function regenerateTaskTitle(taskId: number): Promise<string> {
|
||||||
|
const res = await fetch(`${BASE}/local/tasks/${taskId}/regenerate-title`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data?.error ?? 'Failed to regenerate title');
|
||||||
|
return data.title as string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function continueTaskWithPiece(
|
export async function continueTaskWithPiece(
|
||||||
taskId: number,
|
taskId: number,
|
||||||
body: { piece: string; instruction: string },
|
body: { piece: string; instruction: string },
|
||||||
@ -319,6 +331,11 @@ export function getLocalFileRawUrl(taskId: number, section: 'workspace' | 'input
|
|||||||
return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
|
return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTrustedLocalHtmlUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string {
|
||||||
|
const params = new URLSearchParams({ section, path, trusted: '1' });
|
||||||
|
return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateLocalFileContent(taskId: number, section: string, path: string, content: string): Promise<void> {
|
export async function updateLocalFileContent(taskId: number, section: string, path: string, content: string): Promise<void> {
|
||||||
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content`, {
|
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@ -1266,3 +1283,46 @@ export async function postTestNotification(): Promise<{ ok: boolean }> {
|
|||||||
const res = await fetch(`${BASE}/notifications/test`, { method: 'POST' });
|
const res = await fetch(`${BASE}/notifications/test`, { method: 'POST' });
|
||||||
return notificationsJsonOrThrow(res, 'failed to send test notification');
|
return notificationsJsonOrThrow(res, 'failed to send test notification');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LLM usage dashboard (per-user, gateway + direct).
|
||||||
|
// Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
|
||||||
|
export interface UsageCounters {
|
||||||
|
tokensIn: number;
|
||||||
|
tokensOut: number;
|
||||||
|
requests: number;
|
||||||
|
}
|
||||||
|
export interface UsageBucket {
|
||||||
|
bucket: string; // 'YYYY-MM-DD' | 'YYYY-Www' | 'YYYY-MM'
|
||||||
|
gateway: UsageCounters;
|
||||||
|
direct: UsageCounters;
|
||||||
|
}
|
||||||
|
export interface UsageByUser extends UsageCounters {
|
||||||
|
userId: string;
|
||||||
|
/** Resolved display name (real users); 'local' / 'system' for sentinels. */
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
export interface UsageDailyResponse {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
granularity: 'day' | 'week' | 'month';
|
||||||
|
scope: 'all' | 'self';
|
||||||
|
series: UsageBucket[];
|
||||||
|
totals: { gateway: UsageCounters; direct: UsageCounters };
|
||||||
|
byUser?: UsageByUser[]; // admin / local mode only
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUsageDaily(params: {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
granularity?: 'day' | 'week' | 'month';
|
||||||
|
}): Promise<UsageDailyResponse> {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params.from) qs.set('from', params.from);
|
||||||
|
if (params.to) qs.set('to', params.to);
|
||||||
|
if (params.granularity) qs.set('granularity', params.granularity);
|
||||||
|
const q = qs.toString();
|
||||||
|
const res = await fetch(`${BASE}/usage/daily${q ? `?${q}` : ''}`);
|
||||||
|
if (!res.ok) throw new Error(`Failed to load usage (${res.status})`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|||||||
@ -227,7 +227,7 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div role="tablist" aria-label={t('panel.tabsLabel')} className="hidden sm:flex gap-4 -mb-px">
|
<div role="tablist" aria-label={t('panel.tabsLabel')} className="hidden sm:flex flex-wrap gap-x-4 gap-y-1 -mb-px">
|
||||||
{tabs.map(tab => {
|
{tabs.map(tab => {
|
||||||
const active = activeTab === tab.id;
|
const active = activeTab === tab.id;
|
||||||
const pending = active && tabTransitionPending;
|
const pending = active && tabTransitionPending;
|
||||||
@ -237,7 +237,7 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={active}
|
aria-selected={active}
|
||||||
onClick={() => onTabChange(tab.id)}
|
onClick={() => onTabChange(tab.id)}
|
||||||
className={`pb-2.5 text-xs border-b-2 active:scale-[0.97] transition-[transform,color,border-color] duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring inline-flex items-center gap-1.5 ${
|
className={`whitespace-nowrap pb-2.5 text-xs border-b-2 active:scale-[0.97] transition-[transform,color,border-color] duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring inline-flex items-center gap-1.5 ${
|
||||||
active
|
active
|
||||||
? 'border-accent text-slate-900 font-semibold'
|
? 'border-accent text-slate-900 font-semibold'
|
||||||
: 'border-transparent text-slate-500 font-medium hover:text-slate-800'
|
: 'border-transparent text-slate-500 font-medium hover:text-slate-800'
|
||||||
|
|||||||
@ -104,7 +104,27 @@ export function LocalDetailPanel({
|
|||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
});
|
});
|
||||||
const showSshTab = consoleStatus?.active === true;
|
const showSshTab = consoleStatus?.active === true;
|
||||||
const visibleTabs = LOCAL_TABS.filter((t) => t.id !== 'ssh' || showSshTab);
|
|
||||||
|
// Browser tab visibility: mirror SSH — show only once a viewable browser
|
||||||
|
// session is live for this task. `available: true` means a noVNC session
|
||||||
|
// exists (the agent has actually used the browser); every other case
|
||||||
|
// (no_session / headless_mode / display_unavailable / novnc_not_installed)
|
||||||
|
// is non-viewable, so the tab would show nothing useful and stays hidden.
|
||||||
|
// Shares the ['task-session', id] query with BrowserTab (deduped by key).
|
||||||
|
const { data: browserSession } = useQuery<{ available: boolean }>({
|
||||||
|
queryKey: ['task-session', task?.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const r = await fetch(`/api/local/browser/sessions/task-session/${task!.id}`);
|
||||||
|
return r.ok ? r.json() : { available: false };
|
||||||
|
},
|
||||||
|
enabled: !!task,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
const showBrowserTab = browserSession?.available === true;
|
||||||
|
|
||||||
|
const visibleTabs = LOCAL_TABS.filter(
|
||||||
|
(t) => (t.id !== 'ssh' || showSshTab) && (t.id !== 'browser' || showBrowserTab),
|
||||||
|
);
|
||||||
|
|
||||||
const handleStartEdit = () => {
|
const handleStartEdit = () => {
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { LocalTask, MissionBrief, SubtaskActivity, putFeedback, updateMissionBrief } from '../../../api';
|
import { LocalTask, MissionBrief, SubtaskActivity, putFeedback, updateMissionBrief, updateLocalTask, regenerateTaskTitle } from '../../../api';
|
||||||
import { StatusBadge } from '../../shared/StatusBadge';
|
import { StatusBadge } from '../../shared/StatusBadge';
|
||||||
import { SubtasksPanel, type SubtaskFilePreviewHandler } from './SubtasksPanel';
|
import { SubtasksPanel, type SubtaskFilePreviewHandler } from './SubtasksPanel';
|
||||||
import { ContextUsageGauge } from '../ContextUsageGauge';
|
import { ContextUsageGauge } from '../ContextUsageGauge';
|
||||||
@ -272,6 +272,116 @@ function MissionCard({ task }: { task: LocalTask }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editable task title with on-demand AI regeneration. The title is set to a
|
||||||
|
* cheap fallback at creation and upgraded by the agent (derived from the
|
||||||
|
* Mission Brief goal) during the run. A manual edit pins it (title_source =
|
||||||
|
* 'user') so the agent never overwrites it afterwards.
|
||||||
|
*/
|
||||||
|
function TaskTitleRow({ task }: { task: LocalTask }) {
|
||||||
|
const { t } = useTranslation('detail');
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(task.title);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Sync the displayed title with server-side updates (the agent rewrites it
|
||||||
|
// mid-run) unless the user is actively editing.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing) setDraft(task.title);
|
||||||
|
}, [task.title, editing]);
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['localTaskDetail', task.id] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['localTasks'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: () => updateLocalTask(task.id, { title: draft.trim() }),
|
||||||
|
onSuccess: () => { setEditing(false); setError(null); invalidate(); },
|
||||||
|
onError: (err: unknown) => setError(err instanceof Error ? err.message : 'Failed to save title'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const regenMutation = useMutation({
|
||||||
|
mutationFn: () => regenerateTaskTitle(task.id),
|
||||||
|
onSuccess: () => { setError(null); invalidate(); },
|
||||||
|
onError: (err: unknown) => setError(err instanceof Error ? err.message : 'Failed to regenerate title'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const save = () => { if (draft.trim()) saveMutation.mutate(); };
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<input
|
||||||
|
value={draft}
|
||||||
|
autoFocus
|
||||||
|
maxLength={200}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); save(); }
|
||||||
|
if (e.key === 'Escape') { setEditing(false); setError(null); setDraft(task.title); }
|
||||||
|
}}
|
||||||
|
className="w-full px-2.5 py-1.5 text-lg font-extrabold text-slate-900 border border-hairline rounded-md focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-accent"
|
||||||
|
/>
|
||||||
|
{error && <div className="text-2xs text-red-600">{error}</div>}
|
||||||
|
<div className="flex justify-end gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setEditing(false); setError(null); setDraft(task.title); }}
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
className="px-3 h-7 text-xs rounded-md border border-hairline bg-canvas text-slate-700 hover:bg-surface transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('title.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={save}
|
||||||
|
disabled={saveMutation.isPending || !draft.trim()}
|
||||||
|
className="px-3 h-7 text-xs font-semibold rounded-md bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? t('title.saving') : t('title.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="group flex items-start justify-between gap-2">
|
||||||
|
<div className="text-lg font-extrabold text-slate-900 break-words leading-tight min-w-0">{task.title}</div>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => regenMutation.mutate()}
|
||||||
|
disabled={regenMutation.isPending}
|
||||||
|
title={t('title.regenerate')}
|
||||||
|
aria-label={t('title.regenerate')}
|
||||||
|
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:text-accent hover:bg-surface transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<svg className={`w-3.5 h-3.5 ${regenMutation.isPending ? 'animate-spin' : ''}`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2v3h-3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setDraft(task.title); setEditing(true); setError(null); }}
|
||||||
|
title={t('title.edit')}
|
||||||
|
aria-label={t('title.edit')}
|
||||||
|
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:text-slate-700 hover:bg-surface transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M11.5 2.5l2 2L6 12l-2.5.5L4 10l7.5-7.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-2xs text-red-600 mt-1">{error}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface OverviewTabProps {
|
interface OverviewTabProps {
|
||||||
task: LocalTask;
|
task: LocalTask;
|
||||||
subtaskActivities?: SubtaskActivity[];
|
subtaskActivities?: SubtaskActivity[];
|
||||||
@ -284,7 +394,7 @@ export function OverviewTab({ task, subtaskActivities, onSubtaskFilePreview }: O
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
|
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||||
<div className="text-lg font-extrabold text-slate-900">{task.title}</div>
|
<TaskTitleRow task={task} />
|
||||||
<div className="flex flex-wrap gap-2 mt-2">
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
<StatusBadge status={status} />
|
<StatusBadge status={status} />
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{task.pieceName}</span>
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{task.pieceName}</span>
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LocalFileEntry, getLocalFileRawUrl } from '../../api';
|
import { LocalFileEntry, getLocalFileRawUrl } from '../../api';
|
||||||
import { isPreviewable, formatFileDate } from '../../lib/utils';
|
import { isPreviewable, formatFileDate } from '../../lib/utils';
|
||||||
|
import { splitFileName } from '../../lib/fileType';
|
||||||
|
import { FileTypeIcon } from './FileTypeIcon';
|
||||||
|
|
||||||
interface FileBrowserProps {
|
interface FileBrowserProps {
|
||||||
section: 'workspace' | 'input' | 'output' | 'logs';
|
section: 'workspace' | 'input' | 'output' | 'logs';
|
||||||
@ -211,20 +213,30 @@ export function FileBrowser({
|
|||||||
key={`${entry.kind}:${entry.path}`}
|
key={`${entry.kind}:${entry.path}`}
|
||||||
className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-canvas border border-hairline hover:bg-surface transition-colors"
|
className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-canvas border border-hairline hover:bg-surface transition-colors"
|
||||||
>
|
>
|
||||||
<span className="text-slate-400 flex-shrink-0" aria-hidden="true">
|
<span className="flex-shrink-0" aria-hidden="true">
|
||||||
{entry.kind === 'directory' ? (
|
{entry.kind === 'directory' ? (
|
||||||
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
<svg className="w-4 h-4 text-slate-400" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M2 4.5A1.5 1.5 0 013.5 3h3l1.5 2h4.5A1.5 1.5 0 0114 6.5v5A1.5 1.5 0 0112.5 13h-9A1.5 1.5 0 012 11.5v-7z" />
|
<path d="M2 4.5A1.5 1.5 0 013.5 3h3l1.5 2h4.5A1.5 1.5 0 0114 6.5v5A1.5 1.5 0 0112.5 13h-9A1.5 1.5 0 012 11.5v-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
<FileTypeIcon name={entry.name} />
|
||||||
<path d="M9 2H4a1.5 1.5 0 00-1.5 1.5v9A1.5 1.5 0 004 14h8a1.5 1.5 0 001.5-1.5V6.5L9 2z" />
|
|
||||||
<path d="M9 2v4.5h4.5" />
|
|
||||||
</svg>
|
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-[13px] text-slate-800 truncate" title={entry.name}>{entry.name}</div>
|
{entry.kind === 'file'
|
||||||
|
? (() => {
|
||||||
|
// Truncate the stem but never the extension, so the file
|
||||||
|
// type stays readable on long names: `long-na…me.xlsx`.
|
||||||
|
const { stem, ext } = splitFileName(entry.name);
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline text-[13px] text-slate-800 min-w-0" title={entry.name}>
|
||||||
|
<span className="truncate min-w-0">{stem}</span>
|
||||||
|
{ext && <span className="flex-shrink-0">{ext}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
: <div className="text-[13px] text-slate-800 truncate" title={entry.name}>{entry.name}</div>
|
||||||
|
}
|
||||||
{entry.kind === 'file' && entry.modifiedAt && (
|
{entry.kind === 'file' && entry.modifiedAt && (
|
||||||
<div className="text-[10px] text-slate-400 font-mono leading-tight">{formatFileDate(entry.modifiedAt)}</div>
|
<div className="text-[10px] text-slate-400 font-mono leading-tight">{formatFileDate(entry.modifiedAt)}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -65,6 +65,7 @@ interface FilePreviewProps {
|
|||||||
section?: string;
|
section?: string;
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
|
trustedHtmlUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- CSV ---
|
// --- CSV ---
|
||||||
@ -622,7 +623,7 @@ function renderJsonl(content: string): JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- FilePreview ---
|
// --- FilePreview ---
|
||||||
export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onClose, taskId, section, filePath, editable }: FilePreviewProps) {
|
export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onClose, taskId, section, filePath, editable, trustedHtmlUrl }: FilePreviewProps) {
|
||||||
const { t } = useTranslation('files');
|
const { t } = useTranslation('files');
|
||||||
const [mode, setMode] = useState<'view' | 'edit'>('view');
|
const [mode, setMode] = useState<'view' | 'edit'>('view');
|
||||||
const [editContent, setEditContent] = useState(content);
|
const [editContent, setEditContent] = useState(content);
|
||||||
@ -634,6 +635,7 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
|
|||||||
|
|
||||||
const canEdit = editable && taskId != null && section && filePath;
|
const canEdit = editable && taskId != null && section && filePath;
|
||||||
const isMarkdownFile = /\.(md|markdown)$/i.test(name);
|
const isMarkdownFile = /\.(md|markdown)$/i.test(name);
|
||||||
|
const canOpenTrustedHtml = mode === 'view' && !!trustedHtmlUrl && /\.html?$/i.test(name);
|
||||||
|
|
||||||
const handlePrint = async () => {
|
const handlePrint = async () => {
|
||||||
if (printing) return;
|
if (printing) return;
|
||||||
@ -756,6 +758,21 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
|
|||||||
<div className="flex justify-between items-center px-4 py-2.5 border-b border-hairline flex-shrink-0 sticky top-0 bg-surface z-10 gap-2">
|
<div className="flex justify-between items-center px-4 py-2.5 border-b border-hairline flex-shrink-0 sticky top-0 bg-surface z-10 gap-2">
|
||||||
<div className="font-mono text-xs text-slate-700 truncate" title={name}>{name}</div>
|
<div className="font-mono text-xs text-slate-700 truncate" title={name}>{name}</div>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{canOpenTrustedHtml && (
|
||||||
|
<a
|
||||||
|
href={trustedHtmlUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={t('trustedHtml.tooltip')}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 h-7 text-2xs font-medium rounded-md border border-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M6 3H3.5A1.5 1.5 0 0 0 2 4.5v8A1.5 1.5 0 0 0 3.5 14h8a1.5 1.5 0 0 0 1.5-1.5V10" />
|
||||||
|
<path d="M9 2h5v5M8 8l6-6" />
|
||||||
|
</svg>
|
||||||
|
{t('trustedHtml.button')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
{isMarkdownFile && mode === 'view' && (
|
{isMarkdownFile && mode === 'view' && (
|
||||||
<button
|
<button
|
||||||
onClick={handlePrint}
|
onClick={handlePrint}
|
||||||
|
|||||||
89
ui/src/components/files/FileTypeIcon.tsx
Normal file
89
ui/src/components/files/FileTypeIcon.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { fileCategory, categoryColorClass, type FileCategory } from '../../lib/fileType';
|
||||||
|
|
||||||
|
/** カテゴリごとの 16x16 ストロークグリフ (既存ファイル行のアイコンと同じスタイル)。 */
|
||||||
|
function glyph(cat: FileCategory) {
|
||||||
|
switch (cat) {
|
||||||
|
case 'image':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<rect x="2" y="3" width="12" height="10" rx="1.5" />
|
||||||
|
<circle cx="5.5" cy="6.5" r="1" />
|
||||||
|
<path d="M3 12l3-3 2.5 2.5L11 7l2 2" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'spreadsheet':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<rect x="2.5" y="3" width="11" height="10" rx="1" />
|
||||||
|
<path d="M2.5 6.5h11M2.5 10h11M6 3v10M10 3v10" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'document':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M9 2H4a1.5 1.5 0 00-1.5 1.5v9A1.5 1.5 0 004 14h8a1.5 1.5 0 001.5-1.5V6.5L9 2z" />
|
||||||
|
<path d="M9 2v4.5h4.5" />
|
||||||
|
<path d="M5.5 9h5M5.5 11h3" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'presentation':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<rect x="2" y="3" width="12" height="8" rx="1" />
|
||||||
|
<path d="M8 11v2M6 14h4" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'code':
|
||||||
|
return <path d="M6 5L3 8l3 3M10 5l3 3-3 3" />;
|
||||||
|
case 'archive':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<rect x="3" y="3" width="10" height="11" rx="1" />
|
||||||
|
<path d="M8 3v11" strokeDasharray="1.5 1.5" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'audio':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<circle cx="5.3" cy="11.5" r="1.7" />
|
||||||
|
<circle cx="10.7" cy="9.7" r="1.7" />
|
||||||
|
<path d="M7 11.5V4l5.4-1.5v7.2" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'video':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<rect x="2" y="3.5" width="12" height="9" rx="1.5" />
|
||||||
|
<path d="M7 6.3l3 1.7-3 1.7z" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'pdf':
|
||||||
|
case 'other':
|
||||||
|
default:
|
||||||
|
// 折れ角のあるページ。pdf は色 (赤) で識別、other は灰色。
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M9 2H4a1.5 1.5 0 00-1.5 1.5v9A1.5 1.5 0 004 14h8a1.5 1.5 0 001.5-1.5V6.5L9 2z" />
|
||||||
|
<path d="M9 2v4.5h4.5" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileTypeIcon({ name, className }: { name: string; className?: string }) {
|
||||||
|
const cat = fileCategory(name);
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={`${className ?? 'w-4 h-4'} ${categoryColorClass(cat)}`}
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{glyph(cat)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,7 +5,8 @@ import { useBackdropClose } from '../../lib/useBackdropClose';
|
|||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
id: PageId;
|
id: PageId;
|
||||||
label: string;
|
/** i18n key resolved against the `layout` namespace at render time. */
|
||||||
|
labelKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavDrawerProps {
|
interface NavDrawerProps {
|
||||||
@ -85,6 +86,14 @@ const NAV_ICONS: Record<PageId, ReactNode> = {
|
|||||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
|
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
|
||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
|
usage: (
|
||||||
|
<svg {...ICON_PROPS}>
|
||||||
|
<line x1="4" y1="20" x2="20" y2="20" />
|
||||||
|
<rect x="6" y="11" width="3" height="6" />
|
||||||
|
<rect x="11" y="7" width="3" height="10" />
|
||||||
|
<rect x="16" y="13" width="3" height="4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NavDrawer({
|
export function NavDrawer({
|
||||||
@ -211,7 +220,7 @@ export function NavDrawer({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="flex-shrink-0 text-slate-500">{NAV_ICONS[item.id]}</span>
|
<span className="flex-shrink-0 text-slate-500">{NAV_ICONS[item.id]}</span>
|
||||||
<span className="flex-1 text-left">{item.label}</span>
|
<span className="flex-1 text-left">{t(item.labelKey)}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { PageId } from '../../lib/urlState';
|
import type { PageId } from '../../lib/urlState';
|
||||||
import type { AuthUser } from '../../App';
|
import type { AuthUser } from '../../App';
|
||||||
@ -17,6 +17,9 @@ interface TopBarProps {
|
|||||||
hamburgerButtonRef?: React.RefObject<HTMLButtonElement>;
|
hamburgerButtonRef?: React.RefObject<HTMLButtonElement>;
|
||||||
navDrawerOpen?: boolean;
|
navDrawerOpen?: boolean;
|
||||||
onOpenCommandK?: () => void;
|
onOpenCommandK?: () => void;
|
||||||
|
/** Reports the measured compact state up so App can drive the nav drawer /
|
||||||
|
* edge-swipe. Called whenever the collapse decision flips. */
|
||||||
|
onCompactChange?: (compact: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// labelKey resolves against the `layout` i18n namespace at render time (module
|
// labelKey resolves against the `layout` i18n namespace at render time (module
|
||||||
@ -26,14 +29,29 @@ export const NAV_ITEMS: Array<{ id: PageId; labelKey: string; adminOnly: boolean
|
|||||||
{ id: 'schedules', labelKey: 'nav.schedules', adminOnly: false, requiresAuth: false },
|
{ id: 'schedules', labelKey: 'nav.schedules', adminOnly: false, requiresAuth: false },
|
||||||
{ id: 'pieces', labelKey: 'nav.pieces', adminOnly: false, requiresAuth: false },
|
{ id: 'pieces', labelKey: 'nav.pieces', adminOnly: false, requiresAuth: false },
|
||||||
{ id: 'captcha', labelKey: 'nav.captcha', adminOnly: true, requiresAuth: false },
|
{ id: 'captcha', labelKey: 'nav.captcha', adminOnly: true, requiresAuth: false },
|
||||||
|
{ id: 'usage', labelKey: 'nav.usage', adminOnly: false, requiresAuth: false },
|
||||||
{ id: 'settings', labelKey: 'nav.settings', adminOnly: false, requiresAuth: false },
|
{ id: 'settings', labelKey: 'nav.settings', adminOnly: false, requiresAuth: false },
|
||||||
{ id: 'users', labelKey: 'nav.users', adminOnly: true, requiresAuth: true },
|
{ id: 'users', labelKey: 'nav.users', adminOnly: true, requiresAuth: true },
|
||||||
{ id: 'help', labelKey: 'nav.help', adminOnly: false, requiresAuth: false },
|
{ id: 'help', labelKey: 'nav.help', adminOnly: false, requiresAuth: false },
|
||||||
{ id: 'userfolder', labelKey: 'nav.userfolder', adminOnly: false, requiresAuth: false },
|
{ id: 'userfolder', labelKey: 'nav.userfolder', adminOnly: false, requiresAuth: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function estimateCollapseThreshold(navCount: number): number {
|
/**
|
||||||
return 430 + navCount * 78 + 60;
|
* Decide whether the inline nav must collapse into the hamburger, from MEASURED
|
||||||
|
* widths (not a width estimate). Collapse exactly when the nav's natural width
|
||||||
|
* no longer fits the space left after the fixed left content (logo/name/version)
|
||||||
|
* and the right-side controls. The buffer keeps a small gap so tabs never touch
|
||||||
|
* the controls. Before anything is measured (zero widths) we assume it fits, so
|
||||||
|
* the bar paints with tabs rather than flashing the hamburger.
|
||||||
|
*/
|
||||||
|
export function shouldCollapseNav(
|
||||||
|
rowWidth: number,
|
||||||
|
fixedWidth: number,
|
||||||
|
navWidth: number,
|
||||||
|
buffer = 40,
|
||||||
|
): boolean {
|
||||||
|
if (rowWidth <= 0 || navWidth <= 0) return false;
|
||||||
|
return navWidth > rowWidth - fixedWidth - buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useViewportNarrow(threshold: number): boolean {
|
export function useViewportNarrow(threshold: number): boolean {
|
||||||
@ -58,11 +76,6 @@ export function visibleNavItemsFor(isAdmin: boolean, authEnabled: boolean) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCompactNav(isAdmin: boolean, authEnabled: boolean): boolean {
|
|
||||||
const visible = visibleNavItemsFor(isAdmin, authEnabled);
|
|
||||||
return useViewportNarrow(estimateCollapseThreshold(visible.length));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TopBar({
|
export function TopBar({
|
||||||
currentPage,
|
currentPage,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
@ -75,12 +88,43 @@ export function TopBar({
|
|||||||
hamburgerButtonRef,
|
hamburgerButtonRef,
|
||||||
navDrawerOpen = false,
|
navDrawerOpen = false,
|
||||||
onOpenCommandK,
|
onOpenCommandK,
|
||||||
|
onCompactChange,
|
||||||
}: TopBarProps) {
|
}: TopBarProps) {
|
||||||
const { t } = useTranslation('layout');
|
const { t } = useTranslation('layout');
|
||||||
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
|
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
|
||||||
const compactMode = useViewportNarrow(estimateCollapseThreshold(visibleNav.length));
|
|
||||||
const [showPwChange, setShowPwChange] = useState(false);
|
const [showPwChange, setShowPwChange] = useState(false);
|
||||||
|
|
||||||
|
// Collapse-to-hamburger decision from MEASURED widths, so it flips exactly
|
||||||
|
// when the tabs stop fitting — no width estimate, no 2-line state.
|
||||||
|
const rowRef = useRef<HTMLDivElement>(null);
|
||||||
|
const leftFixedRef = useRef<HTMLDivElement>(null); // logo + appName + version (NOT the nav)
|
||||||
|
const rightRef = useRef<HTMLDivElement>(null); // right-side controls
|
||||||
|
const navRulerRef = useRef<HTMLDivElement>(null); // off-screen full-width nav, always present
|
||||||
|
const [compactMode, setCompactMode] = useState(false);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const row = rowRef.current;
|
||||||
|
if (!row || typeof ResizeObserver === 'undefined') return;
|
||||||
|
const measure = () => {
|
||||||
|
const leftFixed = leftFixedRef.current?.offsetWidth ?? 0;
|
||||||
|
const right = rightRef.current?.offsetWidth ?? 0;
|
||||||
|
const navWidth = navRulerRef.current?.offsetWidth ?? 0;
|
||||||
|
setCompactMode(shouldCollapseNav(row.clientWidth, leftFixed + right, navWidth));
|
||||||
|
};
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(row);
|
||||||
|
// Observe the ruler + right group too: their widths change with language
|
||||||
|
// (label lengths) and login state, which also moves the collapse point.
|
||||||
|
if (navRulerRef.current) ro.observe(navRulerRef.current);
|
||||||
|
if (rightRef.current) ro.observe(rightRef.current);
|
||||||
|
measure();
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [visibleNav.length, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onCompactChange?.(compactMode);
|
||||||
|
}, [compactMode, onCompactChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex-shrink-0 bg-canvas border-b border-hairline px-4 flex items-center"
|
className="flex-shrink-0 bg-canvas border-b border-hairline px-4 flex items-center"
|
||||||
@ -89,7 +133,26 @@ export function TopBar({
|
|||||||
minHeight: 'calc(48px + env(safe-area-inset-top, 0px))',
|
minHeight: 'calc(48px + env(safe-area-inset-top, 0px))',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3 flex-wrap w-full py-1.5">
|
<div ref={rowRef} className="relative flex items-center justify-between gap-3 flex-nowrap w-full py-1.5 min-w-0">
|
||||||
|
{/* Off-screen full-width nav ruler. Always rendered (absolute, out of
|
||||||
|
flow) so its width is measurable even while the visible nav is
|
||||||
|
collapsed — that constant width is what keeps the collapse decision
|
||||||
|
from oscillating. INVARIANT: this ruler must stay a width-SUPERSET
|
||||||
|
of the real nav (same px/gap, every label font-semibold = the widest
|
||||||
|
state). If it ever underestimates the real nav, the 2-line bug
|
||||||
|
returns. Do NOT add per-item active/inactive weight here. */}
|
||||||
|
<nav
|
||||||
|
ref={navRulerRef}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute left-0 top-0 invisible pointer-events-none flex gap-5 ml-2"
|
||||||
|
>
|
||||||
|
{visibleNav.map(item => (
|
||||||
|
<span key={item.id} className="px-0.5 text-xs font-semibold whitespace-nowrap">
|
||||||
|
{t(item.labelKey)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 min-w-0 self-stretch">
|
<div className="flex items-center gap-4 min-w-0 self-stretch">
|
||||||
{compactMode && (
|
{compactMode && (
|
||||||
<button
|
<button
|
||||||
@ -109,6 +172,10 @@ export function TopBar({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* leftFixedRef must wrap ONLY logo/name/version — the hamburger is a
|
||||||
|
sibling above, kept out of this measurement so toggling it doesn't
|
||||||
|
change leftFixed width (which would oscillate the collapse). */}
|
||||||
|
<div ref={leftFixedRef} className="flex items-center gap-4 flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
src={logoUrl ?? `${import.meta.env.BASE_URL}favicon.svg`}
|
src={logoUrl ?? `${import.meta.env.BASE_URL}favicon.svg`}
|
||||||
alt=""
|
alt=""
|
||||||
@ -120,9 +187,10 @@ export function TopBar({
|
|||||||
<span className="text-[10px] font-mono text-slate-400 hidden sm:inline">
|
<span className="text-[10px] font-mono text-slate-400 hidden sm:inline">
|
||||||
v{__APP_VERSION__}
|
v{__APP_VERSION__}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!compactMode && (
|
{!compactMode && (
|
||||||
<nav className="flex gap-5 items-stretch -mb-[13px] ml-2" aria-label={t('nav.mainNav')}>
|
<nav className="flex gap-5 items-stretch -mb-[13px] ml-2 flex-shrink-0" aria-label={t('nav.mainNav')}>
|
||||||
{visibleNav.map(item => {
|
{visibleNav.map(item => {
|
||||||
const active = currentPage === item.id;
|
const active = currentPage === item.id;
|
||||||
return (
|
return (
|
||||||
@ -131,7 +199,7 @@ export function TopBar({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onNavigate(item.id)}
|
onClick={() => onNavigate(item.id)}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
className={`relative px-0.5 pb-3 text-xs border-b-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
|
className={`relative whitespace-nowrap px-0.5 pb-3 text-xs border-b-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
|
||||||
active
|
active
|
||||||
? 'font-semibold text-slate-900 border-accent'
|
? 'font-semibold text-slate-900 border-accent'
|
||||||
: 'font-medium text-slate-500 border-transparent hover:text-slate-800'
|
: 'font-medium text-slate-500 border-transparent hover:text-slate-800'
|
||||||
@ -145,7 +213,7 @@ export function TopBar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div ref={rightRef} className="flex items-center gap-2 flex-shrink-0">
|
||||||
{onOpenCommandK && (
|
{onOpenCommandK && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
30
ui/src/components/layout/topbar-collapse.test.ts
Normal file
30
ui/src/components/layout/topbar-collapse.test.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { shouldCollapseNav } from './TopBar';
|
||||||
|
|
||||||
|
describe('shouldCollapseNav', () => {
|
||||||
|
it('does not collapse while widths are unmeasured (avoids hamburger flash)', () => {
|
||||||
|
expect(shouldCollapseNav(0, 0, 0)).toBe(false);
|
||||||
|
expect(shouldCollapseNav(1000, 300, 0)).toBe(false); // navWidth 0 = not measured yet
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps tabs when the nav fits the leftover space (minus buffer)', () => {
|
||||||
|
// row 1000, fixed 300 → 700 available, minus 40 buffer = 660. nav 600 fits.
|
||||||
|
expect(shouldCollapseNav(1000, 300, 600)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses when the nav no longer fits', () => {
|
||||||
|
// 700 available - 40 buffer = 660. nav 700 does not fit.
|
||||||
|
expect(shouldCollapseNav(1000, 300, 700)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects the buffer at the boundary', () => {
|
||||||
|
// available 660 after buffer; nav exactly 660 fits, 661 collapses.
|
||||||
|
expect(shouldCollapseNav(1000, 300, 660)).toBe(false);
|
||||||
|
expect(shouldCollapseNav(1000, 300, 661)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors a custom buffer', () => {
|
||||||
|
expect(shouldCollapseNav(1000, 300, 690, 10)).toBe(false); // 690 <= 690
|
||||||
|
expect(shouldCollapseNav(1000, 300, 691, 10)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -19,7 +19,7 @@ interface TaskListPanelProps {
|
|||||||
onSelectTask: (id: number) => void;
|
onSelectTask: (id: number) => void;
|
||||||
onOpenCreate: () => void;
|
onOpenCreate: () => void;
|
||||||
/**
|
/**
|
||||||
* Owner scope (mine/all). Only meaningful when scopeEnabled — auth must be
|
* Owner scope (mine/others). Only meaningful when scopeEnabled — auth must be
|
||||||
* on and the viewer known; otherwise everything is owner 'local' and the
|
* on and the viewer known; otherwise everything is owner 'local' and the
|
||||||
* control is hidden.
|
* control is hidden.
|
||||||
*/
|
*/
|
||||||
@ -36,12 +36,12 @@ interface TaskListPanelProps {
|
|||||||
function ScopeToggle({
|
function ScopeToggle({
|
||||||
scope,
|
scope,
|
||||||
mineCount,
|
mineCount,
|
||||||
allCount,
|
othersCount,
|
||||||
onScopeChange,
|
onScopeChange,
|
||||||
}: {
|
}: {
|
||||||
scope: TaskScope;
|
scope: TaskScope;
|
||||||
mineCount: number;
|
mineCount: number;
|
||||||
allCount: number;
|
othersCount: number;
|
||||||
onScopeChange: (scope: TaskScope) => void;
|
onScopeChange: (scope: TaskScope) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation('list');
|
const { t } = useTranslation('list');
|
||||||
@ -62,7 +62,7 @@ function ScopeToggle({
|
|||||||
return (
|
return (
|
||||||
<div className="flex gap-0.5 p-0.5 mb-2 rounded-md bg-canvas border border-hairline" role="group" aria-label={t('scope.aria')}>
|
<div className="flex gap-0.5 p-0.5 mb-2 rounded-md bg-canvas border border-hairline" role="group" aria-label={t('scope.aria')}>
|
||||||
{seg('mine', t('scope.mine'), mineCount)}
|
{seg('mine', t('scope.mine'), mineCount)}
|
||||||
{seg('all', t('scope.all'), allCount)}
|
{seg('others', t('scope.others'), othersCount)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -88,8 +88,11 @@ export function TaskListPanel({
|
|||||||
const { t } = useTranslation('list');
|
const { t } = useTranslation('list');
|
||||||
// Owner scope is the outermost filter: status counts / search / sort all
|
// Owner scope is the outermost filter: status counts / search / sort all
|
||||||
// operate on the scoped list so "自分" mode never counts others' tasks.
|
// operate on the scoped list so "自分" mode never counts others' tasks.
|
||||||
const effectiveScope: TaskScope = scopeEnabled ? scope : 'all';
|
// When the toggle is disabled (no-auth: every task is owner 'local'), the
|
||||||
const localTasks = filterTasksByScope(allTasks, effectiveScope, currentUserId);
|
// scope partition is meaningless — show the full list unfiltered.
|
||||||
|
const localTasks = scopeEnabled
|
||||||
|
? filterTasksByScope(allTasks, scope, currentUserId)
|
||||||
|
: allTasks;
|
||||||
if (mode === 'rail') {
|
if (mode === 'rail') {
|
||||||
const localColumnsRail: Record<string, LocalTask[]> = COLUMN_LIST.reduce((acc, s) => {
|
const localColumnsRail: Record<string, LocalTask[]> = COLUMN_LIST.reduce((acc, s) => {
|
||||||
acc[s] = localTasks.filter(t => (t.latestJob?.status ?? 'queued') === s);
|
acc[s] = localTasks.filter(t => (t.latestJob?.status ?? 'queued') === s);
|
||||||
@ -169,7 +172,7 @@ export function TaskListPanel({
|
|||||||
<ScopeToggle
|
<ScopeToggle
|
||||||
scope={scope}
|
scope={scope}
|
||||||
mineCount={filterTasksByScope(allTasks, 'mine', currentUserId).length}
|
mineCount={filterTasksByScope(allTasks, 'mine', currentUserId).length}
|
||||||
allCount={allTasks.length}
|
othersCount={filterTasksByScope(allTasks, 'others', currentUserId).length}
|
||||||
onScopeChange={onScopeChange}
|
onScopeChange={onScopeChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { NotificationsForm } from './NotificationsForm';
|
|||||||
import { BrandingForm } from './BrandingForm';
|
import { BrandingForm } from './BrandingForm';
|
||||||
import { MemoryLearningForm } from './MemoryLearningForm';
|
import { MemoryLearningForm } from './MemoryLearningForm';
|
||||||
import { MetricsForm } from './MetricsForm';
|
import { MetricsForm } from './MetricsForm';
|
||||||
|
import { ServerTlsForm } from './ServerTlsForm';
|
||||||
import { ReflectionForm } from './ReflectionForm';
|
import { ReflectionForm } from './ReflectionForm';
|
||||||
import { McpForm } from './McpForm';
|
import { McpForm } from './McpForm';
|
||||||
import { SshForm } from './SshForm';
|
import { SshForm } from './SshForm';
|
||||||
@ -195,6 +196,9 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
|||||||
case 'gateway-server': return <GatewayServerForm {...formProps} />;
|
case 'gateway-server': return <GatewayServerForm {...formProps} />;
|
||||||
case 'llm-metrics': return <MetricsForm {...formProps} />;
|
case 'llm-metrics': return <MetricsForm {...formProps} />;
|
||||||
|
|
||||||
|
// ── Server / Network
|
||||||
|
case 'server-tls': return <ServerTlsForm {...formProps} />;
|
||||||
|
|
||||||
// ── Agent Runtime
|
// ── Agent Runtime
|
||||||
case 'ask-subtasks': return <AskSubtasksForm {...formProps} />;
|
case 'ask-subtasks': return <AskSubtasksForm {...formProps} />;
|
||||||
case 'context': return <ContextForm {...formProps} />;
|
case 'context': return <ContextForm {...formProps} />;
|
||||||
|
|||||||
119
ui/src/components/settings/ServerTlsForm.tsx
Normal file
119
ui/src/components/settings/ServerTlsForm.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { HelpText } from './HelpText';
|
||||||
|
import { FieldLabel, FieldInput } from './formUtils';
|
||||||
|
import { StringArrayEditor } from './StringArrayEditor';
|
||||||
|
import type { SectionFormProps } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server TLS settings — binds to the `server.tls` config object.
|
||||||
|
*
|
||||||
|
* Editable fields: enabled, certFile, keyFile, httpRedirect,
|
||||||
|
* httpRedirectPort, redirectHost, selfSignedHosts.
|
||||||
|
*
|
||||||
|
* Fields intentionally omitted from the UI (left to config.yaml):
|
||||||
|
* minVersion, selfSignedDir. The onChange path mechanism in ConfigFormInner
|
||||||
|
* uses setNestedValue which does a shallow-merge, so unedited fields are
|
||||||
|
* preserved on save automatically.
|
||||||
|
*
|
||||||
|
* TODO(server-tls-info): cert source/expiry/fingerprint panel + regenerate
|
||||||
|
* button need a GET /api/server/tls-info endpoint (future).
|
||||||
|
*/
|
||||||
|
export function ServerTlsForm({ config, onChange }: SectionFormProps) {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
|
|
||||||
|
// Navigate to the server.tls sub-object; fall back to empty object if absent.
|
||||||
|
const tls = (config?.server?.tls) ?? {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<h2 className="text-base font-semibold text-slate-800 dark:text-slate-100">
|
||||||
|
{t('serverTls.title')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Restart-required banner — always visible */}
|
||||||
|
<div className="px-3 py-2.5 rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-500/10 dark:border-amber-500/30 text-xs text-amber-800 dark:text-amber-300">
|
||||||
|
{t('serverTls.restartBanner')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enable HTTPS */}
|
||||||
|
<div>
|
||||||
|
<label className="inline-flex items-center gap-2 text-[13px] text-slate-700 dark:text-slate-200">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={tls.enabled === true}
|
||||||
|
onChange={e => onChange('server.tls.enabled', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{t('serverTls.enabled')}</span>
|
||||||
|
</label>
|
||||||
|
<HelpText>{t('serverTls.enabledHelp')}</HelpText>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Certificate file */}
|
||||||
|
<div>
|
||||||
|
<FieldLabel>{t('serverTls.certFile')}</FieldLabel>
|
||||||
|
<FieldInput
|
||||||
|
value={tls.certFile ?? ''}
|
||||||
|
onChange={v => onChange('server.tls.certFile', v || undefined)}
|
||||||
|
placeholder="/etc/ssl/certs/server.pem"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Private key file */}
|
||||||
|
<div>
|
||||||
|
<FieldLabel>{t('serverTls.keyFile')}</FieldLabel>
|
||||||
|
<FieldInput
|
||||||
|
value={tls.keyFile ?? ''}
|
||||||
|
onChange={v => onChange('server.tls.keyFile', v || undefined)}
|
||||||
|
placeholder="/etc/ssl/private/server.key"
|
||||||
|
/>
|
||||||
|
<HelpText>{t('serverTls.certHelp')}</HelpText>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HTTP → HTTPS redirect */}
|
||||||
|
<div>
|
||||||
|
<label className="inline-flex items-center gap-2 text-[13px] text-slate-700 dark:text-slate-200">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={tls.httpRedirect === true}
|
||||||
|
onChange={e => onChange('server.tls.httpRedirect', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{t('serverTls.httpRedirect')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HTTP redirect port */}
|
||||||
|
<div>
|
||||||
|
<FieldLabel>{t('serverTls.httpRedirectPort')}</FieldLabel>
|
||||||
|
<FieldInput
|
||||||
|
type="number"
|
||||||
|
value={tls.httpRedirectPort != null ? String(tls.httpRedirectPort) : ''}
|
||||||
|
onChange={v => {
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
onChange('server.tls.httpRedirectPort', isNaN(n) ? undefined : n);
|
||||||
|
}}
|
||||||
|
placeholder="9080"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Redirect host (optional) */}
|
||||||
|
<div>
|
||||||
|
<FieldLabel>{t('serverTls.redirectHost')}</FieldLabel>
|
||||||
|
<FieldInput
|
||||||
|
value={tls.redirectHost ?? ''}
|
||||||
|
onChange={v => onChange('server.tls.redirectHost', v || undefined)}
|
||||||
|
placeholder="example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional SAN hostnames for self-signed cert */}
|
||||||
|
<div>
|
||||||
|
<FieldLabel>{t('serverTls.selfSignedHosts')}</FieldLabel>
|
||||||
|
<StringArrayEditor
|
||||||
|
value={tls.selfSignedHosts ?? []}
|
||||||
|
onChange={v => onChange('server.tls.selfSignedHosts', v)}
|
||||||
|
placeholder="example.com / 10.0.0.10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -90,6 +90,13 @@ const CONFIG_GROUPS = [
|
|||||||
{ id: 'ssh', label: 'Admin SSH' },
|
{ id: 'ssh', label: 'Admin SSH' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Network',
|
||||||
|
adminOnly: true,
|
||||||
|
sections: [
|
||||||
|
{ id: 'server-tls', label: 'HTTPS / TLS' },
|
||||||
|
],
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
367
ui/src/components/usage/UsagePage.tsx
Normal file
367
ui/src/components/usage/UsagePage.tsx
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getUsageDaily, type UsageBucket, type UsageCounters } from '../../api';
|
||||||
|
|
||||||
|
type Preset = 'last7' | 'last30' | 'last90' | 'ytd' | 'custom';
|
||||||
|
type Gran = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
function utcToday(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
function shiftDay(day: string, delta: number): string {
|
||||||
|
const x = new Date(`${day}T00:00:00.000Z`);
|
||||||
|
x.setUTCDate(x.getUTCDate() + delta);
|
||||||
|
return x.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
function yearStart(): string {
|
||||||
|
return `${new Date().getUTCFullYear()}-01-01`;
|
||||||
|
}
|
||||||
|
function fmtTokens(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
function total(c: UsageCounters): number {
|
||||||
|
return c.tokensIn + c.tokensOut;
|
||||||
|
}
|
||||||
|
function zeroCounters(): UsageCounters {
|
||||||
|
return { tokensIn: 0, tokensOut: 0, requests: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror the server's bucket keys (usage-api.ts) so we can fill empty buckets
|
||||||
|
// and keep the chart's time axis linear instead of index-based.
|
||||||
|
function isoWeekKey(day: string): string {
|
||||||
|
const d = new Date(`${day}T00:00:00.000Z`);
|
||||||
|
const dayNum = (d.getUTCDay() + 6) % 7;
|
||||||
|
d.setUTCDate(d.getUTCDate() - dayNum + 3);
|
||||||
|
const firstThursday = new Date(Date.UTC(d.getUTCFullYear(), 0, 4));
|
||||||
|
const firstDayNum = (firstThursday.getUTCDay() + 6) % 7;
|
||||||
|
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3);
|
||||||
|
const week = 1 + Math.round((d.getTime() - firstThursday.getTime()) / (7 * 86_400_000));
|
||||||
|
return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
function bucketKey(day: string, g: Gran): string {
|
||||||
|
if (g === 'month') return day.slice(0, 7);
|
||||||
|
if (g === 'week') return isoWeekKey(day);
|
||||||
|
return day;
|
||||||
|
}
|
||||||
|
/** Ordered, gap-free bucket list across [from, to] at the chosen granularity. */
|
||||||
|
function denseBuckets(series: UsageBucket[], from: string, to: string, g: Gran): UsageBucket[] {
|
||||||
|
const byKey = new Map(series.map((b) => [b.bucket, b]));
|
||||||
|
const out: UsageBucket[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let day = from;
|
||||||
|
let guard = 0;
|
||||||
|
while (day <= to && guard++ < 2000) {
|
||||||
|
const key = bucketKey(day, g);
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
out.push(byKey.get(key) ?? { bucket: key, gateway: zeroCounters(), direct: zeroCounters() });
|
||||||
|
}
|
||||||
|
day = shiftDay(day, 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeFor(preset: Preset, customFrom: string, customTo: string): { from: string; to: string } {
|
||||||
|
const to = utcToday();
|
||||||
|
switch (preset) {
|
||||||
|
case 'last7': return { from: shiftDay(to, -6), to };
|
||||||
|
case 'last90': return { from: shiftDay(to, -89), to };
|
||||||
|
case 'ytd': return { from: yearStart(), to };
|
||||||
|
case 'custom': return { from: customFrom || shiftDay(to, -29), to: customTo || to };
|
||||||
|
case 'last30':
|
||||||
|
default: return { from: shiftDay(to, -29), to };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GW = '#6366f1'; // indigo-500 — gateway
|
||||||
|
const DR = '#22c55e'; // green-500 — direct
|
||||||
|
|
||||||
|
/** i18next t with interpolation support (widened from the bare key signature). */
|
||||||
|
type TFn = (key: string, opts?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
export function UsagePage() {
|
||||||
|
const { t } = useTranslation('usage');
|
||||||
|
const [preset, setPreset] = useState<Preset>('last30');
|
||||||
|
const [granularity, setGranularity] = useState<Gran>('day');
|
||||||
|
const [customFrom, setCustomFrom] = useState('');
|
||||||
|
const [customTo, setCustomTo] = useState('');
|
||||||
|
|
||||||
|
const { from, to } = rangeFor(preset, customFrom, customTo);
|
||||||
|
// Client-side guard: don't fire a request the server would 400 on; show an
|
||||||
|
// inline message instead of a generic error.
|
||||||
|
const customInvalid = preset === 'custom' && !!customFrom && !!customTo && customFrom > customTo;
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['usage-daily', from, to, granularity],
|
||||||
|
queryFn: () => getUsageDaily({ from, to, granularity }),
|
||||||
|
enabled: !customInvalid,
|
||||||
|
});
|
||||||
|
|
||||||
|
const presets: Preset[] = ['last7', 'last30', 'last90', 'ytd', 'custom'];
|
||||||
|
const grans: Gran[] = ['day', 'week', 'month'];
|
||||||
|
|
||||||
|
const series: UsageBucket[] = data?.series ?? [];
|
||||||
|
// Gap-free buckets so the bar/line x-axis is time-linear, not index-based.
|
||||||
|
const dense = useMemo(
|
||||||
|
() => (data ? denseBuckets(series, data.from, data.to, granularity) : []),
|
||||||
|
[series, data, granularity],
|
||||||
|
);
|
||||||
|
const maxBucket = useMemo(
|
||||||
|
() => dense.reduce((m, b) => Math.max(m, total(b.gateway) + total(b.direct)), 0),
|
||||||
|
[dense],
|
||||||
|
);
|
||||||
|
const cumulative = useMemo(() => {
|
||||||
|
let run = 0;
|
||||||
|
return dense.map((b) => {
|
||||||
|
run += total(b.gateway) + total(b.direct);
|
||||||
|
return run;
|
||||||
|
});
|
||||||
|
}, [dense]);
|
||||||
|
const maxCumulative = cumulative.length ? cumulative[cumulative.length - 1] : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
|
<div className="max-w-5xl mx-auto p-6 space-y-5">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-lg font-semibold text-slate-800 dark:text-slate-100">{t('title')}</h1>
|
||||||
|
<p className="text-[13px] text-slate-500 dark:text-slate-400 mt-1">{t('subtitle')}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{presets.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setPreset(p)}
|
||||||
|
className={`px-2.5 py-1 text-xs rounded-md border transition-colors ${
|
||||||
|
preset === p
|
||||||
|
? 'bg-accent text-white border-accent'
|
||||||
|
: 'border-hairline text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`range.${p}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{preset === 'custom' && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<input type="date" value={customFrom} onChange={(e) => setCustomFrom(e.target.value)}
|
||||||
|
aria-invalid={customInvalid}
|
||||||
|
className="border border-hairline rounded px-1.5 py-1 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 [color-scheme:light] dark:[color-scheme:dark]" />
|
||||||
|
<span className="text-slate-400">→</span>
|
||||||
|
<input type="date" value={customTo} onChange={(e) => setCustomTo(e.target.value)}
|
||||||
|
aria-invalid={customInvalid}
|
||||||
|
className="border border-hairline rounded px-1.5 py-1 bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-200 [color-scheme:light] dark:[color-scheme:dark]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1 ml-auto">
|
||||||
|
<span className="text-xs text-slate-400 mr-1">{t('granularity.label')}</span>
|
||||||
|
{grans.map((g) => (
|
||||||
|
<button
|
||||||
|
key={g}
|
||||||
|
onClick={() => setGranularity(g)}
|
||||||
|
className={`px-2 py-1 text-xs rounded-md border transition-colors ${
|
||||||
|
granularity === g
|
||||||
|
? 'bg-accent text-white border-accent'
|
||||||
|
: 'border-hairline text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`granularity.${g}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customInvalid && <div className="text-sm text-amber-600 dark:text-amber-400">{t('range.invalid')}</div>}
|
||||||
|
{!customInvalid && isLoading && <div className="text-sm text-slate-400 italic">{t('loading')}</div>}
|
||||||
|
{!customInvalid && error && <div className="text-sm text-red-600">{t('error')}</div>}
|
||||||
|
|
||||||
|
{!customInvalid && data && (
|
||||||
|
<>
|
||||||
|
<TotalsCards totals={data.totals} t={t} />
|
||||||
|
|
||||||
|
{series.length === 0 ? (
|
||||||
|
<div className="border border-hairline rounded-lg p-8 text-center text-sm text-slate-400 italic">
|
||||||
|
{t('empty')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<StackedBars series={dense} maxBucket={maxBucket} t={t} />
|
||||||
|
<CumulativeLine series={dense} cumulative={cumulative} max={maxCumulative} t={t} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.byUser && data.byUser.length > 0 && <ByUserTable rows={data.byUser} t={t} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TotalsCards({ totals, t }: { totals: { gateway: UsageCounters; direct: UsageCounters }; t: TFn }) {
|
||||||
|
const combined: UsageCounters = {
|
||||||
|
tokensIn: totals.gateway.tokensIn + totals.direct.tokensIn,
|
||||||
|
tokensOut: totals.gateway.tokensOut + totals.direct.tokensOut,
|
||||||
|
requests: totals.gateway.requests + totals.direct.requests,
|
||||||
|
};
|
||||||
|
const card = (label: string, c: UsageCounters, dot?: string) => (
|
||||||
|
<div className="border border-hairline rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
|
{dot && <span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: dot }} />}
|
||||||
|
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] text-slate-400">{t('totals.input')}</div>
|
||||||
|
<div className="font-mono">{fmtTokens(c.tokensIn)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] text-slate-400">{t('totals.output')}</div>
|
||||||
|
<div className="font-mono">{fmtTokens(c.tokensOut)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] text-slate-400">{t('totals.requests')}</div>
|
||||||
|
<div className="font-mono">{c.requests.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
{card(t('totals.combined'), combined)}
|
||||||
|
{card(t('totals.gateway'), totals.gateway, GW)}
|
||||||
|
{card(t('totals.direct'), totals.direct, DR)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StackedBars({ series, maxBucket, t }: { series: UsageBucket[]; maxBucket: number; t: TFn }) {
|
||||||
|
const grand = series.reduce((s, b) => s + total(b.gateway) + total(b.direct), 0);
|
||||||
|
const ariaLabel = t('chart.barsAria', { title: t('chart.tokensTitle'), total: fmtTokens(grand), count: series.length });
|
||||||
|
return (
|
||||||
|
<div className="border border-hairline rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">{t('chart.tokensTitle')}</span>
|
||||||
|
<Legend t={t} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-1 h-44" role="img" aria-label={ariaLabel}>
|
||||||
|
{series.map((b) => {
|
||||||
|
const g = total(b.gateway);
|
||||||
|
const d = total(b.direct);
|
||||||
|
const sum = g + d;
|
||||||
|
const hPct = maxBucket > 0 ? (sum / maxBucket) * 100 : 0;
|
||||||
|
const gPct = sum > 0 ? (g / sum) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={b.bucket} className="flex-1 min-w-0 flex flex-col items-center group relative">
|
||||||
|
<div className="w-full flex flex-col justify-end" style={{ height: '100%' }}>
|
||||||
|
<div className="w-full rounded-t-sm overflow-hidden flex flex-col" style={{ height: `${Math.max(hPct, sum > 0 ? 2 : 0)}%` }}>
|
||||||
|
<div style={{ height: `${gPct}%`, background: GW }} />
|
||||||
|
<div style={{ height: `${100 - gPct}%`, background: DR }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Tooltip (decorative — chart summary is exposed via aria-label) */}
|
||||||
|
<div aria-hidden="true" className="pointer-events-none absolute bottom-full mb-1 hidden group-hover:block z-10 whitespace-nowrap bg-slate-800 text-white text-[10px] rounded px-1.5 py-1 shadow">
|
||||||
|
<div className="font-mono">{b.bucket}</div>
|
||||||
|
<div><span style={{ color: GW }}>■</span> {fmtTokens(g)}</div>
|
||||||
|
<div><span style={{ color: DR }}>■</span> {fmtTokens(d)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-2 text-[10px] text-slate-400 font-mono">
|
||||||
|
<span>{series[0]?.bucket}</span>
|
||||||
|
<span>{series[series.length - 1]?.bucket}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CumulativeLine({ series, cumulative, max, t }: { series: UsageBucket[]; cumulative: number[]; max: number; t: TFn }) {
|
||||||
|
const W = 600;
|
||||||
|
const H = 140;
|
||||||
|
const pad = 4;
|
||||||
|
const n = cumulative.length;
|
||||||
|
const coords = cumulative.map((v, i) => {
|
||||||
|
const x = n <= 1 ? W / 2 : pad + (i / (n - 1)) * (W - 2 * pad);
|
||||||
|
const y = max > 0 ? H - pad - (v / max) * (H - 2 * pad) : H - pad;
|
||||||
|
return { x, y };
|
||||||
|
});
|
||||||
|
const points = coords.map((c) => `${c.x.toFixed(1)},${c.y.toFixed(1)}`).join(' ');
|
||||||
|
const ariaLabel = t('chart.lineAria', { total: fmtTokens(max) });
|
||||||
|
return (
|
||||||
|
<div className="border border-hairline rounded-lg p-4">
|
||||||
|
<div className="flex items-baseline justify-between mb-3">
|
||||||
|
<span className="text-xs font-medium text-slate-500 uppercase tracking-wide">{t('chart.cumulativeTitle')}</span>
|
||||||
|
<span className="text-[11px] text-slate-500 font-mono">{t('chart.total', { value: fmtTokens(max) })}</span>
|
||||||
|
</div>
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-36" role="img" aria-label={ariaLabel}>
|
||||||
|
{/* A single bucket can't form a line — draw the point so it's visible. */}
|
||||||
|
{n === 1 ? (
|
||||||
|
<circle cx={coords[0].x} cy={coords[0].y} r={3} fill="var(--accent, #6366f1)" vectorEffect="non-scaling-stroke" />
|
||||||
|
) : (
|
||||||
|
<polyline points={points} fill="none" stroke="var(--accent, #6366f1)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
<div className="flex justify-between mt-1 text-[10px] text-slate-400 font-mono">
|
||||||
|
<span>{series[0]?.bucket}</span>
|
||||||
|
<span>{series[series.length - 1]?.bucket}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Legend({ t }: { t: TFn }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 text-[11px] text-slate-500">
|
||||||
|
<span className="flex items-center gap-1"><span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: GW }} />{t('chart.legendGateway')}</span>
|
||||||
|
<span className="flex items-center gap-1"><span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ background: DR }} />{t('chart.legendDirect')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ByUserTable({ rows, t }: { rows: Array<{ userId: string; displayName: string } & UsageCounters>; t: TFn }) {
|
||||||
|
// Localize the sentinels; real users show their resolved name with the id as
|
||||||
|
// a secondary line.
|
||||||
|
const label = (r: { userId: string; displayName: string }): { primary: string; secondary?: string } => {
|
||||||
|
if (r.userId === 'local') return { primary: t('byUser.local') };
|
||||||
|
if (r.userId === 'system') return { primary: t('byUser.system') };
|
||||||
|
return { primary: r.displayName, secondary: r.displayName === r.userId ? undefined : r.userId };
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="border border-hairline rounded-lg p-4">
|
||||||
|
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-3">{t('byUser.title')}</div>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-[11px] text-slate-400 text-left">
|
||||||
|
<th className="font-medium pb-1">{t('byUser.user')}</th>
|
||||||
|
<th className="font-medium pb-1 text-right">{t('byUser.input')}</th>
|
||||||
|
<th className="font-medium pb-1 text-right">{t('byUser.output')}</th>
|
||||||
|
<th className="font-medium pb-1 text-right">{t('byUser.requests')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => {
|
||||||
|
const l = label(r);
|
||||||
|
return (
|
||||||
|
<tr key={r.userId} className="border-t border-hairline">
|
||||||
|
<td className="py-1.5 text-slate-600 dark:text-slate-300">
|
||||||
|
<span>{l.primary}</span>
|
||||||
|
{l.secondary && <span className="ml-1.5 text-[11px] text-slate-400 font-mono">{l.secondary}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 text-right font-mono">{fmtTokens(r.tokensIn)}</td>
|
||||||
|
<td className="py-1.5 text-right font-mono">{fmtTokens(r.tokensOut)}</td>
|
||||||
|
<td className="py-1.5 text-right font-mono">{r.requests.toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import {
|
|||||||
fetchLocalFileContent,
|
fetchLocalFileContent,
|
||||||
fetchSubtaskFileContent,
|
fetchSubtaskFileContent,
|
||||||
getLocalFileRawUrl,
|
getLocalFileRawUrl,
|
||||||
|
getTrustedLocalHtmlUrl,
|
||||||
subtaskFileRawUrl,
|
subtaskFileRawUrl,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import { isImagePreviewable, isPdfPreviewable, isTextPreviewable, isHtmlPreviewable } from '../lib/utils';
|
import { isImagePreviewable, isPdfPreviewable, isTextPreviewable, isHtmlPreviewable } from '../lib/utils';
|
||||||
@ -17,6 +18,7 @@ export interface PreviewState {
|
|||||||
section?: string;
|
section?: string;
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
|
trustedHtmlUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFilePreview(onError: (msg: string) => void) {
|
export function useFilePreview(onError: (msg: string) => void) {
|
||||||
@ -32,7 +34,11 @@ export function useFilePreview(onError: (msg: string) => void) {
|
|||||||
try {
|
try {
|
||||||
const canEdit = section === 'output' && isTextPreviewable(name);
|
const canEdit = section === 'output' && isTextPreviewable(name);
|
||||||
if (isImagePreviewable(name) || isPdfPreviewable(name) || isHtmlPreviewable(name)) {
|
if (isImagePreviewable(name) || isPdfPreviewable(name) || isHtmlPreviewable(name)) {
|
||||||
setPreviewState({ name, content: '', imageSrc: getLocalFileRawUrl(taskId, section, filePath), taskId, section, filePath, editable: false });
|
const imageSrc = getLocalFileRawUrl(taskId, section, filePath);
|
||||||
|
const trustedHtmlUrl = isHtmlPreviewable(name)
|
||||||
|
? getTrustedLocalHtmlUrl(taskId, section, filePath)
|
||||||
|
: undefined;
|
||||||
|
setPreviewState({ name, content: '', imageSrc, trustedHtmlUrl, taskId, section, filePath, editable: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = await fetchLocalFileContent(taskId, section, filePath);
|
const content = await fetchLocalFileContent(taskId, section, filePath);
|
||||||
|
|||||||
@ -21,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
"focus": { "toStandard": "Back to standard view", "toFocused": "Focused mode (TASK column to a thin rail / split chat and workspace)", "toFocusedShort": "Switch to focused mode" },
|
"focus": { "toStandard": "Back to standard view", "toFocused": "Focused mode (TASK column to a thin rail / split chat and workspace)", "toFocusedShort": "Switch to focused mode" },
|
||||||
"feedback": { "title": "Feedback", "change": "Change", "good": "Good", "bad": "Needs improvement", "commentPlaceholder": "Comment (optional)", "cancel": "Cancel", "submitting": "Submitting...", "submit": "Submit" },
|
"feedback": { "title": "Feedback", "change": "Change", "good": "Good", "bad": "Needs improvement", "commentPlaceholder": "Comment (optional)", "cancel": "Cancel", "submitting": "Submitting...", "submit": "Submit" },
|
||||||
|
"title": { "edit": "Edit title", "regenerate": "Regenerate title with AI", "save": "Save", "saving": "Saving...", "cancel": "Cancel" },
|
||||||
"mission": {
|
"mission": {
|
||||||
"pinnedMemo": "pinned memo", "edit": "Edit", "cancel": "Cancel", "saving": "Saving...", "save": "Save",
|
"pinnedMemo": "pinned memo", "edit": "Edit", "cancel": "Cancel", "saving": "Saving...", "save": "Save",
|
||||||
"emptyHelp": "No Mission Brief yet. The agent writes here automatically as needed, but pinning the goal / progress / remaining work manually keeps the essence in view even through long conversations.",
|
"emptyHelp": "No Mission Brief yet. The agent writes here automatically as needed, but pinning the goal / progress / remaining work manually keeps the essence in view even through long conversations.",
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
"preparing": "Preparing...",
|
"preparing": "Preparing...",
|
||||||
"button": "PDF / Print"
|
"button": "PDF / Print"
|
||||||
},
|
},
|
||||||
|
"trustedHtml": { "button": "Open as trusted", "tooltip": "Open in a new tab with scripts allowed" },
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
|||||||
@ -9,7 +9,8 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
"userfolder": "User Folder"
|
"userfolder": "User Folder",
|
||||||
|
"usage": "Usage"
|
||||||
},
|
},
|
||||||
"commandPalette": {
|
"commandPalette": {
|
||||||
"open": "Open command palette",
|
"open": "Open command palette",
|
||||||
|
|||||||
@ -41,6 +41,6 @@
|
|||||||
"scope": {
|
"scope": {
|
||||||
"aria": "Task display scope",
|
"aria": "Task display scope",
|
||||||
"mine": "Mine",
|
"mine": "Mine",
|
||||||
"all": "All"
|
"others": "Others"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -779,5 +779,18 @@
|
|||||||
},
|
},
|
||||||
"settingsPage": {
|
"settingsPage": {
|
||||||
"sectionList": "Section list"
|
"sectionList": "Section list"
|
||||||
|
},
|
||||||
|
"serverTls": {
|
||||||
|
"title": "HTTPS / TLS",
|
||||||
|
"enabled": "Serve over HTTPS",
|
||||||
|
"enabledHelp": "Terminate TLS in the app. Self-signed by default — browsers (and the noVNC / SSH console over wss) will warn until you install a real certificate. Requires a restart to apply.",
|
||||||
|
"certFile": "Certificate file (PEM)",
|
||||||
|
"keyFile": "Private key file (PEM)",
|
||||||
|
"certHelp": "Leave both empty to use an auto-generated self-signed certificate.",
|
||||||
|
"httpRedirect": "Redirect HTTP to HTTPS",
|
||||||
|
"httpRedirectPort": "HTTP redirect port",
|
||||||
|
"redirectHost": "Redirect host (optional)",
|
||||||
|
"selfSignedHosts": "Additional certificate hostnames",
|
||||||
|
"restartBanner": "Changes to HTTPS settings require a server restart to take effect."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
ui/src/i18n/locales/en/usage.json
Normal file
47
ui/src/i18n/locales/en/usage.json
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"title": "LLM Usage",
|
||||||
|
"subtitle": "Combined token usage across AAO Gateway and direct calls, aggregated per UTC day. This is a separate view from the gateway per-key billing panel.",
|
||||||
|
"range": {
|
||||||
|
"last7": "Last 7 days",
|
||||||
|
"last30": "Last 30 days",
|
||||||
|
"last90": "Last 90 days",
|
||||||
|
"ytd": "Year to date",
|
||||||
|
"custom": "Custom",
|
||||||
|
"invalid": "Start date must be on or before the end date."
|
||||||
|
},
|
||||||
|
"granularity": {
|
||||||
|
"label": "Granularity",
|
||||||
|
"day": "Day",
|
||||||
|
"week": "Week",
|
||||||
|
"month": "Month"
|
||||||
|
},
|
||||||
|
"totals": {
|
||||||
|
"input": "Input tokens",
|
||||||
|
"output": "Output tokens",
|
||||||
|
"requests": "Requests",
|
||||||
|
"gateway": "Gateway",
|
||||||
|
"direct": "Direct",
|
||||||
|
"combined": "Combined"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"tokensTitle": "Tokens by period (gateway vs direct)",
|
||||||
|
"cumulativeTitle": "Cumulative tokens",
|
||||||
|
"legendGateway": "Gateway",
|
||||||
|
"legendDirect": "Direct",
|
||||||
|
"total": "Total {{value}}",
|
||||||
|
"barsAria": "{{title}}: {{total}} tokens total across {{count}} periods",
|
||||||
|
"lineAria": "Cumulative tokens over time, reaching {{total}} total"
|
||||||
|
},
|
||||||
|
"byUser": {
|
||||||
|
"title": "By user",
|
||||||
|
"user": "User",
|
||||||
|
"input": "Input",
|
||||||
|
"output": "Output",
|
||||||
|
"requests": "Requests",
|
||||||
|
"local": "Local",
|
||||||
|
"system": "System"
|
||||||
|
},
|
||||||
|
"empty": "No usage recorded in this range yet. Aggregation starts from the day this feature went live; past usage cannot be backfilled.",
|
||||||
|
"loading": "Loading usage…",
|
||||||
|
"error": "Failed to load usage."
|
||||||
|
}
|
||||||
@ -21,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
"focus": { "toStandard": "標準表示に戻る", "toFocused": "集中モード (TASK 列を細い rail に / Chat と Workspace を可変分割)", "toFocusedShort": "集中モードに切替" },
|
"focus": { "toStandard": "標準表示に戻る", "toFocused": "集中モード (TASK 列を細い rail に / Chat と Workspace を可変分割)", "toFocusedShort": "集中モードに切替" },
|
||||||
"feedback": { "title": "フィードバック", "change": "変更", "good": "良かった", "bad": "改善が必要", "commentPlaceholder": "コメント(任意)", "cancel": "キャンセル", "submitting": "送信中...", "submit": "送信" },
|
"feedback": { "title": "フィードバック", "change": "変更", "good": "良かった", "bad": "改善が必要", "commentPlaceholder": "コメント(任意)", "cancel": "キャンセル", "submitting": "送信中...", "submit": "送信" },
|
||||||
|
"title": { "edit": "タイトルを編集", "regenerate": "AIでタイトルを再生成", "save": "保存", "saving": "保存中...", "cancel": "キャンセル" },
|
||||||
"mission": {
|
"mission": {
|
||||||
"pinnedMemo": "固定メモ", "edit": "編集", "cancel": "キャンセル", "saving": "保存中...", "save": "保存",
|
"pinnedMemo": "固定メモ", "edit": "編集", "cancel": "キャンセル", "saving": "保存中...", "save": "保存",
|
||||||
"emptyHelp": "まだ Mission Brief は設定されていません。エージェントが必要に応じて自動で書き込みますが、手動で目標 / 進捗 / 残タスクをここに固定しておくことで、長い会話の途中でも本質を見失わないように誘導できます。",
|
"emptyHelp": "まだ Mission Brief は設定されていません。エージェントが必要に応じて自動で書き込みますが、手動で目標 / 進捗 / 残タスクをここに固定しておくことで、長い会話の途中でも本質を見失わないように誘導できます。",
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
"preparing": "準備中...",
|
"preparing": "準備中...",
|
||||||
"button": "PDF / 印刷"
|
"button": "PDF / 印刷"
|
||||||
},
|
},
|
||||||
|
"trustedHtml": { "button": "信頼済みとして開く", "tooltip": "スクリプトを許可して別タブで開く" },
|
||||||
"cancel": "キャンセル",
|
"cancel": "キャンセル",
|
||||||
"saving": "保存中...",
|
"saving": "保存中...",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
|
|||||||
@ -9,7 +9,8 @@
|
|||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"users": "ユーザー",
|
"users": "ユーザー",
|
||||||
"help": "ヘルプ",
|
"help": "ヘルプ",
|
||||||
"userfolder": "ユーザーフォルダ"
|
"userfolder": "ユーザーフォルダ",
|
||||||
|
"usage": "使用量"
|
||||||
},
|
},
|
||||||
"commandPalette": {
|
"commandPalette": {
|
||||||
"open": "コマンドパレットを開く",
|
"open": "コマンドパレットを開く",
|
||||||
|
|||||||
@ -41,6 +41,6 @@
|
|||||||
"scope": {
|
"scope": {
|
||||||
"aria": "タスクの表示範囲",
|
"aria": "タスクの表示範囲",
|
||||||
"mine": "自分",
|
"mine": "自分",
|
||||||
"all": "すべて"
|
"others": "他のユーザ"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -779,5 +779,18 @@
|
|||||||
},
|
},
|
||||||
"settingsPage": {
|
"settingsPage": {
|
||||||
"sectionList": "セクション一覧"
|
"sectionList": "セクション一覧"
|
||||||
|
},
|
||||||
|
"serverTls": {
|
||||||
|
"title": "HTTPS / TLS",
|
||||||
|
"enabled": "HTTPS で配信",
|
||||||
|
"enabledHelp": "アプリ内で TLS を終端します。デフォルトは自己署名証明書のため、正式な証明書を導入するまでブラウザ(および wss 経由の noVNC・SSH コンソール)に警告が表示されます。反映にはサーバーの再起動が必要です。",
|
||||||
|
"certFile": "証明書ファイル(PEM)",
|
||||||
|
"keyFile": "秘密鍵ファイル(PEM)",
|
||||||
|
"certHelp": "両方を空欄にすると、自動生成の自己署名証明書を使用します。",
|
||||||
|
"httpRedirect": "HTTP を HTTPS へリダイレクト",
|
||||||
|
"httpRedirectPort": "HTTP リダイレクトポート",
|
||||||
|
"redirectHost": "リダイレクト先ホスト(省略可)",
|
||||||
|
"selfSignedHosts": "証明書の追加ホスト名",
|
||||||
|
"restartBanner": "HTTPS 設定の変更を反映するには、サーバーの再起動が必要です。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
ui/src/i18n/locales/ja/usage.json
Normal file
47
ui/src/i18n/locales/ja/usage.json
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"title": "LLM 使用量",
|
||||||
|
"subtitle": "AAO Gateway 経由と Direct を合算したトークン使用量を UTC 日次で集計します。ゲートウェイのキー別課金パネルとは別集計です。",
|
||||||
|
"range": {
|
||||||
|
"last7": "直近7日",
|
||||||
|
"last30": "直近30日",
|
||||||
|
"last90": "直近90日",
|
||||||
|
"ytd": "年初来",
|
||||||
|
"custom": "カスタム",
|
||||||
|
"invalid": "開始日は終了日以前にしてください。"
|
||||||
|
},
|
||||||
|
"granularity": {
|
||||||
|
"label": "粒度",
|
||||||
|
"day": "日",
|
||||||
|
"week": "週",
|
||||||
|
"month": "月"
|
||||||
|
},
|
||||||
|
"totals": {
|
||||||
|
"input": "入力トークン",
|
||||||
|
"output": "出力トークン",
|
||||||
|
"requests": "リクエスト",
|
||||||
|
"gateway": "Gateway",
|
||||||
|
"direct": "Direct",
|
||||||
|
"combined": "合計"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"tokensTitle": "期間別トークン(Gateway / Direct)",
|
||||||
|
"cumulativeTitle": "累積トークン",
|
||||||
|
"legendGateway": "Gateway",
|
||||||
|
"legendDirect": "Direct",
|
||||||
|
"total": "合計 {{value}}",
|
||||||
|
"barsAria": "{{title}}:合計 {{total}} トークン、{{count}} 期間",
|
||||||
|
"lineAria": "累積トークンの推移、合計 {{total}} に到達"
|
||||||
|
},
|
||||||
|
"byUser": {
|
||||||
|
"title": "ユーザー別",
|
||||||
|
"user": "ユーザー",
|
||||||
|
"input": "入力",
|
||||||
|
"output": "出力",
|
||||||
|
"requests": "リクエスト",
|
||||||
|
"local": "ローカル",
|
||||||
|
"system": "システム"
|
||||||
|
},
|
||||||
|
"empty": "この期間の使用量はまだありません。集計はこの機能の稼働開始日から始まり、過去分は遡って集計できません。",
|
||||||
|
"loading": "使用量を読み込み中…",
|
||||||
|
"error": "使用量の読み込みに失敗しました。"
|
||||||
|
}
|
||||||
71
ui/src/lib/cronForm.test.ts
Normal file
71
ui/src/lib/cronForm.test.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { cronToFormState } from './cronForm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The backend's convertToCron (src/scheduler.ts) — mirrored here so the test
|
||||||
|
* asserts a true round-trip: editing a saved schedule and saving it back must
|
||||||
|
* not change the cron expression.
|
||||||
|
*/
|
||||||
|
function convertToCron(f: ReturnType<typeof cronToFormState>): string {
|
||||||
|
const m = f.minute ?? 0;
|
||||||
|
const h = f.hour ?? 0;
|
||||||
|
switch (f.scheduleType) {
|
||||||
|
case 'daily': return `${m} ${h} * * *`;
|
||||||
|
case 'weekly': return `${m} ${h} * * ${f.dayOfWeek ?? 0}`;
|
||||||
|
case 'monthly': return `${m} ${h} ${f.dayOfMonth ?? 1} * *`;
|
||||||
|
case 'cron': return f.cronExpression;
|
||||||
|
case 'once': return 'once';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('cronToFormState', () => {
|
||||||
|
it('classifies simple presets (daily/weekly/monthly)', () => {
|
||||||
|
expect(cronToFormState('0 9 * * *')).toMatchObject({ scheduleType: 'daily', hour: 9, minute: 0 });
|
||||||
|
expect(cronToFormState('30 14 * * *')).toMatchObject({ scheduleType: 'daily', hour: 14, minute: 30 });
|
||||||
|
expect(cronToFormState('0 9 * * 3')).toMatchObject({ scheduleType: 'weekly', hour: 9, minute: 0, dayOfWeek: 3 });
|
||||||
|
expect(cronToFormState('0 9 15 * *')).toMatchObject({ scheduleType: 'monthly', hour: 9, minute: 0, dayOfMonth: 15 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps custom expressions as 'cron' and preserves the raw expression", () => {
|
||||||
|
for (const expr of [
|
||||||
|
'0 9 * * 1-5', '*/30 * * * *', '0 */2 * * *', '0 9,17 * * *', '15 9 1,15 * *', '0 9 1 6 *',
|
||||||
|
'0 9 * * 7', // Sunday-as-7 is out of the editor's 0-6 select range
|
||||||
|
'0 9 * * *', // double space → 6 tokens, not a clean preset
|
||||||
|
]) {
|
||||||
|
const f = cronToFormState(expr);
|
||||||
|
expect(f.scheduleType).toBe('cron');
|
||||||
|
expect(f.cronExpression).toBe(expr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies Sunday-as-0 weekly', () => {
|
||||||
|
expect(cronToFormState('0 9 * * 0')).toMatchObject({ scheduleType: 'weekly', dayOfWeek: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never produces NaN fields for custom expressions', () => {
|
||||||
|
const f = cronToFormState('0 9 * * 1-5');
|
||||||
|
expect(Number.isNaN(f.hour)).toBe(false);
|
||||||
|
expect(Number.isNaN(f.minute)).toBe(false);
|
||||||
|
expect(Number.isNaN(f.dayOfWeek)).toBe(false);
|
||||||
|
expect(Number.isNaN(f.dayOfMonth)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 'once' to the once type", () => {
|
||||||
|
expect(cronToFormState('once')).toMatchObject({ scheduleType: 'once' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a malformed (non 5-field) string as a raw cron expression', () => {
|
||||||
|
expect(cronToFormState('garbage')).toMatchObject({ scheduleType: 'cron', cronExpression: 'garbage' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips losslessly through the backend convertToCron', () => {
|
||||||
|
const cases = [
|
||||||
|
'0 9 * * *', '30 14 * * *', '0 9 * * 3', '0 9 15 * *',
|
||||||
|
'0 9 * * 1-5', '*/30 * * * *', '0 */2 * * *', '0 9,17 * * *',
|
||||||
|
'15 9 1,15 * *', '0 9 1 6 *', 'once',
|
||||||
|
];
|
||||||
|
for (const expr of cases) {
|
||||||
|
expect(convertToCron(cronToFormState(expr))).toBe(expr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
73
ui/src/lib/cronForm.ts
Normal file
73
ui/src/lib/cronForm.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* cronForm.ts — 保存済み cron 文字列を、スケジュール編集フォームのフィールドに
|
||||||
|
* 復元する純関数。
|
||||||
|
*
|
||||||
|
* スケジュールは DB 上ではすべて cron 文字列なので、編集時にどの「種別」
|
||||||
|
* (daily / weekly / monthly / cron / once) として表示するかをここで判定する。
|
||||||
|
*
|
||||||
|
* 重要: daily/weekly/monthly のプリセットへ変換するのは、対象フィールドが
|
||||||
|
* **単純な整数** (例: `0 9 * * *`) のときだけ。範囲 (`1-5`) / ステップ (`*/30`) /
|
||||||
|
* リスト (`9,17`) / 非 `*` の月を含む式は、プリセットでは表現できない。これらを
|
||||||
|
* 無理にプリセット化すると `Number()` が NaN を返してフィールドが壊れ、かつ
|
||||||
|
* 元の cron 式が失われる。そうした式は `cron` 種別のまま生の式を保持する。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ScheduleType = 'daily' | 'weekly' | 'monthly' | 'cron' | 'once';
|
||||||
|
|
||||||
|
export interface ScheduleFields {
|
||||||
|
scheduleType: ScheduleType;
|
||||||
|
hour: number;
|
||||||
|
minute: number;
|
||||||
|
dayOfWeek: number;
|
||||||
|
dayOfMonth: number;
|
||||||
|
cronExpression: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInt = (s: string): boolean => /^\d+$/.test(s);
|
||||||
|
const inRange = (s: string, lo: number, hi: number): boolean => {
|
||||||
|
if (!isInt(s)) return false;
|
||||||
|
const n = Number(s);
|
||||||
|
return n >= lo && n <= hi;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function cronToFormState(cron: string): ScheduleFields {
|
||||||
|
// 任意の cron 文字列をそのまま保持するフォールバック (生の式を捨てない)。
|
||||||
|
const asCron: ScheduleFields = {
|
||||||
|
scheduleType: 'cron',
|
||||||
|
hour: 9,
|
||||||
|
minute: 0,
|
||||||
|
dayOfWeek: 1,
|
||||||
|
dayOfMonth: 1,
|
||||||
|
cronExpression: cron,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cron === 'once') {
|
||||||
|
return { scheduleType: 'once', hour: 9, minute: 0, dayOfWeek: 1, dayOfMonth: 1, cronExpression: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = cron.split(' ');
|
||||||
|
if (parts.length !== 5) return asCron;
|
||||||
|
|
||||||
|
const [min, hour, dom, mon, dow] = parts;
|
||||||
|
// プリセットは「分・時が整数」かつ「月は毎月 (*)」が前提。満たさなければ cron 扱い。
|
||||||
|
if (mon !== '*' || !isInt(min) || !isInt(hour)) return asCron;
|
||||||
|
|
||||||
|
const h = Number(hour);
|
||||||
|
const m = Number(min);
|
||||||
|
|
||||||
|
if (dom === '*' && dow === '*') {
|
||||||
|
return { scheduleType: 'daily', hour: h, minute: m, dayOfWeek: 1, dayOfMonth: 1, cronExpression: '' };
|
||||||
|
}
|
||||||
|
// weekly/monthly only when the day value is within the editor's select range
|
||||||
|
// (dow 0-6, dom 1-31). Out-of-range forms like `* * * * 7` (Sunday-as-7) stay
|
||||||
|
// as raw cron so re-saving never rewrites them to a different value.
|
||||||
|
if (dom === '*' && inRange(dow, 0, 6)) {
|
||||||
|
return { scheduleType: 'weekly', hour: h, minute: m, dayOfWeek: Number(dow), dayOfMonth: 1, cronExpression: '' };
|
||||||
|
}
|
||||||
|
if (inRange(dom, 1, 31) && dow === '*') {
|
||||||
|
return { scheduleType: 'monthly', hour: h, minute: m, dayOfWeek: 1, dayOfMonth: Number(dom), cronExpression: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// dom と dow が両方指定など、プリセットに当てはまらない組み合わせは cron 扱い。
|
||||||
|
return asCron;
|
||||||
|
}
|
||||||
44
ui/src/lib/fileType.test.ts
Normal file
44
ui/src/lib/fileType.test.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { splitFileName, fileCategory, categoryColorClass } from './fileType';
|
||||||
|
|
||||||
|
describe('splitFileName', () => {
|
||||||
|
it('splits a normal name into stem + ext (ext keeps the dot)', () => {
|
||||||
|
expect(splitFileName('report.xlsx')).toEqual({ stem: 'report', ext: '.xlsx' });
|
||||||
|
});
|
||||||
|
it('uses the LAST dot for multi-dot names', () => {
|
||||||
|
expect(splitFileName('archive.tar.gz')).toEqual({ stem: 'archive.tar', ext: '.gz' });
|
||||||
|
});
|
||||||
|
it('treats a leading-dot dotfile as having no extension', () => {
|
||||||
|
expect(splitFileName('.gitignore')).toEqual({ stem: '.gitignore', ext: '' });
|
||||||
|
});
|
||||||
|
it('returns empty ext when there is no dot', () => {
|
||||||
|
expect(splitFileName('README')).toEqual({ stem: 'README', ext: '' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fileCategory', () => {
|
||||||
|
it('maps known extensions (case-insensitive)', () => {
|
||||||
|
expect(fileCategory('a.PNG')).toBe('image');
|
||||||
|
expect(fileCategory('a.pdf')).toBe('pdf');
|
||||||
|
expect(fileCategory('a.xlsx')).toBe('spreadsheet');
|
||||||
|
expect(fileCategory('a.md')).toBe('document');
|
||||||
|
expect(fileCategory('a.pptx')).toBe('presentation');
|
||||||
|
expect(fileCategory('main.ts')).toBe('code');
|
||||||
|
expect(fileCategory('a.zip')).toBe('archive');
|
||||||
|
expect(fileCategory('a.mp3')).toBe('audio');
|
||||||
|
expect(fileCategory('a.mp4')).toBe('video');
|
||||||
|
});
|
||||||
|
it('falls back to other for unknown or missing extensions', () => {
|
||||||
|
expect(fileCategory('a.unknownext')).toBe('other');
|
||||||
|
expect(fileCategory('README')).toBe('other');
|
||||||
|
expect(fileCategory('.gitignore')).toBe('other');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('categoryColorClass', () => {
|
||||||
|
it('returns a literal tailwind text-color class for every category', () => {
|
||||||
|
for (const cat of ['image', 'pdf', 'spreadsheet', 'document', 'presentation', 'code', 'archive', 'audio', 'video', 'other'] as const) {
|
||||||
|
expect(categoryColorClass(cat)).toMatch(/^text-\w+-\d{3}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
75
ui/src/lib/fileType.ts
Normal file
75
ui/src/lib/fileType.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* fileType.ts — ファイル名から拡張子・種別カテゴリ・表示色を導く純関数。
|
||||||
|
*
|
||||||
|
* ファイルタブで (a) 種別ごとの色付きアイコン表示、(b) 長い名前でも拡張子を
|
||||||
|
* 常に見せる中間省略表示、の両方に使う。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type FileCategory =
|
||||||
|
| 'image'
|
||||||
|
| 'pdf'
|
||||||
|
| 'spreadsheet'
|
||||||
|
| 'document'
|
||||||
|
| 'presentation'
|
||||||
|
| 'code'
|
||||||
|
| 'archive'
|
||||||
|
| 'audio'
|
||||||
|
| 'video'
|
||||||
|
| 'other';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ファイル名を「本体」と「拡張子」に分割する。拡張子には先頭の `.` を含む。
|
||||||
|
* - `report.xlsx` → { stem: 'report', ext: '.xlsx' }
|
||||||
|
* - `archive.tar.gz` → { stem: 'archive.tar', ext: '.gz' }(最後のドット基準)
|
||||||
|
* - `.gitignore`(ドットファイル)→ { stem: '.gitignore', ext: '' }
|
||||||
|
* - `README`(拡張子なし)→ { stem: 'README', ext: '' }
|
||||||
|
*/
|
||||||
|
export function splitFileName(name: string): { stem: string; ext: string } {
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
if (dot <= 0) return { stem: name, ext: '' }; // 先頭ドット(ドットファイル) or ドット無し
|
||||||
|
return { stem: name.slice(0, dot), ext: name.slice(dot) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拡張子(小文字・ドット無し) → カテゴリ
|
||||||
|
const EXT_CATEGORY: Record<string, FileCategory> = {};
|
||||||
|
const register = (cat: FileCategory, exts: string[]) => {
|
||||||
|
for (const e of exts) EXT_CATEGORY[e] = cat;
|
||||||
|
};
|
||||||
|
register('image', ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'tif', 'tiff', 'heic']);
|
||||||
|
register('pdf', ['pdf']);
|
||||||
|
register('spreadsheet', ['xlsx', 'xls', 'xlsm', 'csv', 'tsv', 'ods']);
|
||||||
|
register('document', ['doc', 'docx', 'odt', 'rtf', 'txt', 'md', 'markdown', 'log']);
|
||||||
|
register('presentation', ['ppt', 'pptx', 'odp', 'key']);
|
||||||
|
register('code', [
|
||||||
|
'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'py', 'rb', 'go', 'rs', 'java', 'kt',
|
||||||
|
'c', 'cc', 'cpp', 'h', 'hpp', 'cs', 'php', 'swift', 'sh', 'bash', 'zsh', 'sql',
|
||||||
|
'json', 'yaml', 'yml', 'toml', 'xml', 'html', 'htm', 'css', 'scss', 'less', 'ipynb',
|
||||||
|
]);
|
||||||
|
register('archive', ['zip', 'tar', 'gz', 'tgz', 'rar', '7z', 'bz2', 'xz', 'zst']);
|
||||||
|
register('audio', ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac', 'opus']);
|
||||||
|
register('video', ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v']);
|
||||||
|
|
||||||
|
export function fileCategory(name: string): FileCategory {
|
||||||
|
const { ext } = splitFileName(name);
|
||||||
|
if (!ext) return 'other';
|
||||||
|
return EXT_CATEGORY[ext.slice(1).toLowerCase()] ?? 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tailwind の content スキャンに拾わせるため、色クラスはリテラル文字列で持つ
|
||||||
|
// (動的に `text-${x}-500` を組み立てると JIT が purge する)。
|
||||||
|
const CATEGORY_COLOR: Record<FileCategory, string> = {
|
||||||
|
image: 'text-cyan-500',
|
||||||
|
pdf: 'text-red-500',
|
||||||
|
spreadsheet: 'text-green-600',
|
||||||
|
document: 'text-blue-500',
|
||||||
|
presentation: 'text-orange-500',
|
||||||
|
code: 'text-indigo-500',
|
||||||
|
archive: 'text-amber-500',
|
||||||
|
audio: 'text-purple-500',
|
||||||
|
video: 'text-pink-500',
|
||||||
|
other: 'text-slate-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function categoryColorClass(cat: FileCategory): string {
|
||||||
|
return CATEGORY_COLOR[cat];
|
||||||
|
}
|
||||||
@ -14,12 +14,20 @@ describe('filterTasksByScope', () => {
|
|||||||
expect(filterTasksByScope(tasks, 'mine', 'alice').map(t => t.id)).toEqual([1, 3]);
|
expect(filterTasksByScope(tasks, 'mine', 'alice').map(t => t.id)).toEqual([1, 3]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scope='all' returns everything", () => {
|
it("scope='others' keeps everyone else's tasks (incl. legacy null owners)", () => {
|
||||||
expect(filterTasksByScope(tasks, 'all', 'alice')).toHaveLength(5);
|
expect(filterTasksByScope(tasks, 'others', 'alice').map(t => t.id)).toEqual([2, 4, 5]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('no current user (auth disabled) returns everything even for mine', () => {
|
it('mine + others partition the full list with no overlap', () => {
|
||||||
|
const mine = filterTasksByScope(tasks, 'mine', 'alice').map(t => t.id);
|
||||||
|
const others = filterTasksByScope(tasks, 'others', 'alice').map(t => t.id);
|
||||||
|
expect([...mine, ...others].sort()).toEqual([1, 2, 3, 4, 5]);
|
||||||
|
expect(mine.filter(id => others.includes(id))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no current user (auth disabled) returns everything for either scope', () => {
|
||||||
expect(filterTasksByScope(tasks, 'mine', null)).toHaveLength(5);
|
expect(filterTasksByScope(tasks, 'mine', null)).toHaveLength(5);
|
||||||
|
expect(filterTasksByScope(tasks, 'others', null)).toHaveLength(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('legacy null/undefined owners never match mine', () => {
|
it('legacy null/undefined owners never match mine', () => {
|
||||||
|
|||||||
@ -2,20 +2,23 @@
|
|||||||
* taskScope.ts — タスク一覧の所有者スコープフィルタ。
|
* taskScope.ts — タスク一覧の所有者スコープフィルタ。
|
||||||
*
|
*
|
||||||
* visibility (public / org) のせいで他ユーザーのタスクが一覧に混ざり
|
* visibility (public / org) のせいで他ユーザーのタスクが一覧に混ざり
|
||||||
* 「自分のタスクを見失う」問題への対策。'mine' は ownerId が自分のものだけを
|
* 「自分のタスクを見失う」問題への対策。重複のない2分割:
|
||||||
* 残す。認証無効時 (currentUserId が無い時) はフィルタ自体が無意味なので
|
* 'mine' — ownerId が自分のものだけ
|
||||||
* 全件を返す。
|
* 'others' — それ以外 (他ユーザー + legacy null owner)
|
||||||
|
* 認証無効時 (currentUserId が無い時) はフィルタ自体が無意味なので全件を返す。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type TaskScope = 'mine' | 'all';
|
export type TaskScope = 'mine' | 'others';
|
||||||
|
|
||||||
export const TASK_SCOPES: TaskScope[] = ['mine', 'all'];
|
export const TASK_SCOPES: TaskScope[] = ['mine', 'others'];
|
||||||
|
|
||||||
export function filterTasksByScope<T extends { ownerId?: string | null }>(
|
export function filterTasksByScope<T extends { ownerId?: string | null }>(
|
||||||
tasks: T[],
|
tasks: T[],
|
||||||
scope: TaskScope,
|
scope: TaskScope,
|
||||||
currentUserId: string | null,
|
currentUserId: string | null,
|
||||||
): T[] {
|
): T[] {
|
||||||
if (scope !== 'mine' || !currentUserId) return tasks;
|
if (!currentUserId) return tasks;
|
||||||
return tasks.filter(t => t.ownerId === currentUserId);
|
return scope === 'mine'
|
||||||
|
? tasks.filter(t => t.ownerId === currentUserId)
|
||||||
|
: tasks.filter(t => t.ownerId !== currentUserId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -139,7 +139,7 @@ describe('buildUiUrlStateSearch', () => {
|
|||||||
status: 'waiting_human',
|
status: 'waiting_human',
|
||||||
search: 'foo bar',
|
search: 'foo bar',
|
||||||
sort: 'status',
|
sort: 'status',
|
||||||
scope: 'all',
|
scope: 'others',
|
||||||
detailTab: 'trace',
|
detailTab: 'trace',
|
||||||
mobileTab: 'files',
|
mobileTab: 'files',
|
||||||
taskId: 99,
|
taskId: 99,
|
||||||
|
|||||||
@ -2,7 +2,7 @@ const COLUMNS = ['queued', 'running', 'waiting_human', 'waiting_subtasks', 'retr
|
|||||||
const DETAIL_TABS = ['overview', 'activity', 'files', 'trace', 'browser', 'ssh'] as const;
|
const DETAIL_TABS = ['overview', 'activity', 'files', 'trace', 'browser', 'ssh'] as const;
|
||||||
const MOBILE_TABS = ['chat', 'overview', 'activity', 'files', 'trace', 'browser', 'ssh'] as const;
|
const MOBILE_TABS = ['chat', 'overview', 'activity', 'files', 'trace', 'browser', 'ssh'] as const;
|
||||||
const SORT_MODES = ['updated', 'status', 'title'] as const;
|
const SORT_MODES = ['updated', 'status', 'title'] as const;
|
||||||
const PAGES = ['tasks', 'pieces', 'settings', 'schedules', 'users', 'captcha', 'userfolder', 'help'] as const;
|
const PAGES = ['tasks', 'pieces', 'settings', 'schedules', 'users', 'captcha', 'userfolder', 'usage', 'help'] as const;
|
||||||
const SETTINGS_SECTIONS = [
|
const SETTINGS_SECTIONS = [
|
||||||
// User group
|
// User group
|
||||||
'preferences',
|
'preferences',
|
||||||
@ -76,8 +76,9 @@ export interface UiUrlState {
|
|||||||
status: 'all' | StatusColumn;
|
status: 'all' | StatusColumn;
|
||||||
search: string;
|
search: string;
|
||||||
sort: SortMode;
|
sort: SortMode;
|
||||||
/** Owner scope for the task list. 'mine' = own tasks only (default when auth is on). */
|
/** Owner scope for the task list. 'mine' = own tasks only (default when auth is on),
|
||||||
scope: 'mine' | 'all';
|
* 'others' = everyone else's tasks. */
|
||||||
|
scope: 'mine' | 'others';
|
||||||
detailTab: DetailTabId;
|
detailTab: DetailTabId;
|
||||||
mobileTab: MobileTabId;
|
mobileTab: MobileTabId;
|
||||||
taskId: number | null;
|
taskId: number | null;
|
||||||
@ -131,7 +132,7 @@ export function readUiUrlState(): UiUrlState {
|
|||||||
: 'all',
|
: 'all',
|
||||||
search: params.get('q') ?? '',
|
search: params.get('q') ?? '',
|
||||||
sort: sort && SORT_MODES.includes(sort as SortMode) ? sort as SortMode : 'updated',
|
sort: sort && SORT_MODES.includes(sort as SortMode) ? sort as SortMode : 'updated',
|
||||||
scope: params.get('scope') === 'all' ? 'all' : 'mine',
|
scope: params.get('scope') === 'others' ? 'others' : 'mine',
|
||||||
detailTab: detailTab && DETAIL_TABS.includes(detailTab as DetailTabId)
|
detailTab: detailTab && DETAIL_TABS.includes(detailTab as DetailTabId)
|
||||||
? detailTab as DetailTabId
|
? detailTab as DetailTabId
|
||||||
: 'overview',
|
: 'overview',
|
||||||
@ -154,7 +155,7 @@ export function buildUiUrlStateSearch(state: UiUrlState): string {
|
|||||||
if (state.status !== 'all') params.set('status', state.status);
|
if (state.status !== 'all') params.set('status', state.status);
|
||||||
if (state.search) params.set('q', state.search);
|
if (state.search) params.set('q', state.search);
|
||||||
if (state.sort !== 'updated') params.set('sort', state.sort);
|
if (state.sort !== 'updated') params.set('sort', state.sort);
|
||||||
if (state.scope === 'all') params.set('scope', 'all');
|
if (state.scope === 'others') params.set('scope', 'others');
|
||||||
if (state.detailTab !== 'overview') params.set('tab', state.detailTab);
|
if (state.detailTab !== 'overview') params.set('tab', state.detailTab);
|
||||||
if (state.mobileTab !== 'chat') params.set('mobileTab', state.mobileTab);
|
if (state.mobileTab !== 'chat') params.set('mobileTab', state.mobileTab);
|
||||||
if (state.taskId) params.set('task', String(state.taskId));
|
if (state.taskId) params.set('task', String(state.taskId));
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { StatChip } from '../components/shared/StatChip';
|
|||||||
import { usePieceList } from '../hooks/usePieces';
|
import { usePieceList } from '../hooks/usePieces';
|
||||||
import { resolvePieceOptions } from '../lib/splitPieces';
|
import { resolvePieceOptions } from '../lib/splitPieces';
|
||||||
import { ownerDisplayName } from '../lib/owner';
|
import { ownerDisplayName } from '../lib/owner';
|
||||||
|
import { cronToFormState } from '../lib/cronForm';
|
||||||
import { fetchMyOrgs, listBrowserSessionProfiles, type Visibility } from '../api';
|
import { fetchMyOrgs, listBrowserSessionProfiles, type Visibility } from '../api';
|
||||||
import { useAuthState } from '../App';
|
import { useAuthState } from '../App';
|
||||||
|
|
||||||
@ -95,18 +96,10 @@ function parseCronToDisplay(cron: string, t: TFunction): string {
|
|||||||
return cron;
|
return cron;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cronToFormState(cron: string): Pick<ScheduleFormState, 'scheduleType' | 'hour' | 'minute' | 'dayOfWeek' | 'dayOfMonth' | 'cronExpression'> {
|
// cronToFormState lives in ../lib/cronForm so it can be unit-tested in isolation.
|
||||||
if (cron === 'once') return { scheduleType: 'once', hour: 9, minute: 0, dayOfWeek: 1, dayOfMonth: 1, cronExpression: '' };
|
// It only maps to daily/weekly/monthly presets when every field is a simple
|
||||||
const parts = cron.split(' ');
|
// integer; custom expressions (ranges/steps/lists) stay as raw 'cron' so editing
|
||||||
if (parts.length !== 5) return { scheduleType: 'cron', hour: 9, minute: 0, dayOfWeek: 1, dayOfMonth: 1, cronExpression: cron };
|
// a saved schedule never mangles it to NaN or drops the expression.
|
||||||
const [min, hour, dom, , dow] = parts;
|
|
||||||
const h = Number(hour), m = Number(min);
|
|
||||||
|
|
||||||
if (dom !== '*' && dow === '*') return { scheduleType: 'monthly', hour: h, minute: m, dayOfWeek: 1, dayOfMonth: Number(dom), cronExpression: '' };
|
|
||||||
if (dow !== '*' && dom === '*') return { scheduleType: 'weekly', hour: h, minute: m, dayOfWeek: Number(dow), dayOfMonth: 1, cronExpression: '' };
|
|
||||||
if (dom === '*' && dow === '*') return { scheduleType: 'daily', hour: h, minute: m, dayOfWeek: 1, dayOfMonth: 1, cronExpression: '' };
|
|
||||||
return { scheduleType: 'cron', hour: h, minute: m, dayOfWeek: 1, dayOfMonth: 1, cronExpression: cron };
|
|
||||||
}
|
|
||||||
|
|
||||||
function taskToFormState(task: ScheduledTask): ScheduleFormState {
|
function taskToFormState(task: ScheduledTask): ScheduleFormState {
|
||||||
const cronState = cronToFormState(task.cronExpression);
|
const cronState = cronToFormState(task.cronExpression);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user