arkaos 2.80.0 → 2.82.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/VERSION +1 -1
- package/core/profile/__init__.py +9 -0
- package/core/profile/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/profile/__pycache__/manager.cpython-313.pyc +0 -0
- package/core/profile/manager.py +165 -0
- package/dashboard/app/pages/budget.vue +328 -76
- package/dashboard/app/pages/settings.vue +349 -79
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/dashboard-api.py +133 -0
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.82.0
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Profile manager — safe read/write of ~/.arkaos/profile.json (PR63 v2.81.0).
|
|
2
|
+
|
|
3
|
+
The profile is operator-local user data:
|
|
4
|
+
- identity (name, company, role)
|
|
5
|
+
- market context (language, market)
|
|
6
|
+
- filesystem context (projectsDir, vaultPath)
|
|
7
|
+
- timestamps (created, updated)
|
|
8
|
+
|
|
9
|
+
Used by:
|
|
10
|
+
- Sync engine (`core/sync/engine.py`) to discover project directories
|
|
11
|
+
from `projectsDir`
|
|
12
|
+
- Dashboard Settings page (PR63) for editing
|
|
13
|
+
- Various skills to greet by name and route by market
|
|
14
|
+
|
|
15
|
+
Lives at ``~/.arkaos/profile.json`` per ADR
|
|
16
|
+
`docs/adr/2026-04-17-user-data-separation.md`. The manager NEVER
|
|
17
|
+
raises on disk errors — read returns a default ``Profile``, write
|
|
18
|
+
swallows OSError so a failed save is logged but doesn't break the
|
|
19
|
+
caller.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from dataclasses import dataclass, field, asdict
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Optional
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
DEFAULT_PROFILE_PATH = Path.home() / ".arkaos" / "profile.json"
|
|
32
|
+
|
|
33
|
+
# Fields the API accepts in a POST payload — anything else is ignored
|
|
34
|
+
# so callers can't sneak in arbitrary JSON.
|
|
35
|
+
_WRITABLE_FIELDS = frozenset({
|
|
36
|
+
"name", "language", "market", "role", "company",
|
|
37
|
+
"projectsDir", "vaultPath",
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Profile:
|
|
43
|
+
"""Operator profile stored at ``~/.arkaos/profile.json``."""
|
|
44
|
+
|
|
45
|
+
version: str = "2"
|
|
46
|
+
name: str = ""
|
|
47
|
+
language: str = "en"
|
|
48
|
+
market: str = ""
|
|
49
|
+
role: str = ""
|
|
50
|
+
company: str = ""
|
|
51
|
+
projectsDir: str = ""
|
|
52
|
+
vaultPath: str = ""
|
|
53
|
+
created: str = ""
|
|
54
|
+
updated: str = ""
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_dict(cls, data: dict) -> "Profile":
|
|
58
|
+
"""Build a Profile from a JSON dict, dropping unknown keys."""
|
|
59
|
+
if not isinstance(data, dict):
|
|
60
|
+
return cls()
|
|
61
|
+
known = {
|
|
62
|
+
f.name: data[f.name]
|
|
63
|
+
for f in cls.__dataclass_fields__.values() # type: ignore[attr-defined]
|
|
64
|
+
if f.name in data and data[f.name] is not None
|
|
65
|
+
}
|
|
66
|
+
# Pydantic-free defensive conversion: every field must be a string.
|
|
67
|
+
for key, value in list(known.items()):
|
|
68
|
+
if not isinstance(value, str):
|
|
69
|
+
known[key] = str(value)
|
|
70
|
+
return cls(**known)
|
|
71
|
+
|
|
72
|
+
def to_dict(self) -> dict:
|
|
73
|
+
return asdict(self)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ProfileManager:
|
|
77
|
+
"""Read / patch / write the operator profile.
|
|
78
|
+
|
|
79
|
+
Always returns a ``Profile`` (the dataclass) — even when the file
|
|
80
|
+
doesn't exist or is unparseable. The default Profile carries empty
|
|
81
|
+
strings everywhere; the dashboard renders that as a setup CTA.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
85
|
+
self._path = path or DEFAULT_PROFILE_PATH
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def path(self) -> Path:
|
|
89
|
+
return self._path
|
|
90
|
+
|
|
91
|
+
def read(self) -> Profile:
|
|
92
|
+
"""Return the current profile, or a default Profile on any error."""
|
|
93
|
+
if not self._path.exists():
|
|
94
|
+
return Profile()
|
|
95
|
+
try:
|
|
96
|
+
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
97
|
+
except (json.JSONDecodeError, OSError):
|
|
98
|
+
return Profile()
|
|
99
|
+
return Profile.from_dict(data)
|
|
100
|
+
|
|
101
|
+
def patch(self, updates: dict[str, Any]) -> Profile:
|
|
102
|
+
"""Merge ``updates`` into the stored profile and persist.
|
|
103
|
+
|
|
104
|
+
- Drops any key not in ``_WRITABLE_FIELDS``.
|
|
105
|
+
- Coerces values to strings (the schema is all-string).
|
|
106
|
+
- Bumps ``updated`` to the current UTC timestamp.
|
|
107
|
+
- Initialises ``created`` if absent.
|
|
108
|
+
- Atomic write (.tmp + os.replace).
|
|
109
|
+
- Returns the new Profile.
|
|
110
|
+
"""
|
|
111
|
+
current = self.read()
|
|
112
|
+
sanitized = {
|
|
113
|
+
k: ("" if v is None else str(v))
|
|
114
|
+
for k, v in updates.items()
|
|
115
|
+
if k in _WRITABLE_FIELDS
|
|
116
|
+
}
|
|
117
|
+
merged = {**current.to_dict(), **sanitized}
|
|
118
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
119
|
+
merged["updated"] = now
|
|
120
|
+
if not merged.get("created"):
|
|
121
|
+
merged["created"] = now
|
|
122
|
+
merged["version"] = "2"
|
|
123
|
+
self._write(merged)
|
|
124
|
+
return Profile.from_dict(merged)
|
|
125
|
+
|
|
126
|
+
def _write(self, data: dict) -> None:
|
|
127
|
+
try:
|
|
128
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
|
|
130
|
+
tmp.write_text(
|
|
131
|
+
json.dumps(data, indent=2, ensure_ascii=False),
|
|
132
|
+
encoding="utf-8",
|
|
133
|
+
)
|
|
134
|
+
tmp.replace(self._path)
|
|
135
|
+
except OSError:
|
|
136
|
+
# Caller still gets a Profile back from patch(); persistence
|
|
137
|
+
# failure is logged via stderr by upstream callers when
|
|
138
|
+
# appropriate. We never raise.
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ─── Helpers ────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def parse_projects_dirs(value: str) -> list[str]:
|
|
146
|
+
"""Split the free-text ``projectsDir`` field into individual paths.
|
|
147
|
+
|
|
148
|
+
The historical schema stored e.g.
|
|
149
|
+
"/Users/foo/Herd para Laravel, /Users/foo/Work para Nuxt"
|
|
150
|
+
so the parser walks the comma-separated segments and keeps anything
|
|
151
|
+
that starts with ``/`` (POSIX absolute) or ``~/`` (home-relative).
|
|
152
|
+
"""
|
|
153
|
+
if not value:
|
|
154
|
+
return []
|
|
155
|
+
out: list[str] = []
|
|
156
|
+
for raw in value.split(","):
|
|
157
|
+
token = raw.strip()
|
|
158
|
+
if not token:
|
|
159
|
+
continue
|
|
160
|
+
# First whitespace-delimited word that looks like a path wins.
|
|
161
|
+
for word in token.split():
|
|
162
|
+
if word.startswith("/") or word.startswith("~/"):
|
|
163
|
+
out.append(word)
|
|
164
|
+
break
|
|
165
|
+
return out
|
|
@@ -1,36 +1,203 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
// PR65 v2.82.0 — Budget rebuild backed by the PR47 LLM cost telemetry.
|
|
3
|
+
//
|
|
4
|
+
// The legacy /api/budget endpoint surfaces tokens-only ops counts. PR65
|
|
5
|
+
// adds /api/llm-costs (full PR47 CostSummary) and /api/llm-costs/trend
|
|
6
|
+
// (daily rollups), so this page can now show real cost USD by
|
|
7
|
+
// provider / model / category plus a 7-day spend chart.
|
|
8
|
+
|
|
9
|
+
interface BreakdownRow {
|
|
10
|
+
total_cost_usd: number | null
|
|
11
|
+
any_cost_known?: boolean
|
|
12
|
+
total_tokens_in: number
|
|
13
|
+
total_tokens_out: number
|
|
14
|
+
total_cached_tokens: number
|
|
15
|
+
call_count: number
|
|
16
|
+
cache_hit_rate: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface CostSummary {
|
|
20
|
+
period: string
|
|
21
|
+
total_cost_usd: number | null
|
|
22
|
+
total_tokens_in: number
|
|
23
|
+
total_tokens_out: number
|
|
24
|
+
total_cached_tokens: number
|
|
25
|
+
cache_hit_rate: number
|
|
26
|
+
call_count: number
|
|
27
|
+
by_provider: Record<string, BreakdownRow>
|
|
28
|
+
by_model: Record<string, BreakdownRow>
|
|
29
|
+
by_category: Record<string, BreakdownRow>
|
|
30
|
+
by_session: Array<{
|
|
31
|
+
session_id: string
|
|
32
|
+
call_count: number
|
|
33
|
+
total_tokens_in: number
|
|
34
|
+
total_tokens_out: number
|
|
35
|
+
total_cost_usd: number | null
|
|
36
|
+
}>
|
|
37
|
+
advisories: string[]
|
|
38
|
+
corrupt_line_count: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface TrendDay {
|
|
42
|
+
date: string
|
|
43
|
+
cost_usd: number | null
|
|
44
|
+
tokens_in: number
|
|
45
|
+
tokens_out: number
|
|
46
|
+
call_count: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface TrendResponse {
|
|
50
|
+
days: TrendDay[]
|
|
51
|
+
period_days: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type Period = 'today' | 'week' | 'month' | 'all'
|
|
55
|
+
|
|
2
56
|
const { fetchApi } = useApi()
|
|
57
|
+
const period = ref<Period>('today')
|
|
58
|
+
|
|
59
|
+
const periodOptions: { label: string; value: Period }[] = [
|
|
60
|
+
{ label: 'Today', value: 'today' },
|
|
61
|
+
{ label: '7 days', value: 'week' },
|
|
62
|
+
{ label: '30 days', value: 'month' },
|
|
63
|
+
{ label: 'All time', value: 'all' },
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
const {
|
|
67
|
+
data: costs,
|
|
68
|
+
status,
|
|
69
|
+
error,
|
|
70
|
+
refresh,
|
|
71
|
+
} = fetchApi<CostSummary>(
|
|
72
|
+
'/api/llm-costs',
|
|
73
|
+
{ query: computed(() => ({ period: period.value })) },
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const {
|
|
77
|
+
data: trend,
|
|
78
|
+
refresh: refreshTrend,
|
|
79
|
+
} = fetchApi<TrendResponse>('/api/llm-costs/trend?days=7')
|
|
80
|
+
|
|
81
|
+
watch(period, async () => {
|
|
82
|
+
await refresh()
|
|
83
|
+
})
|
|
3
84
|
|
|
4
|
-
|
|
85
|
+
// ─── View tabs ───────────────────────────────────────────────────────────
|
|
5
86
|
|
|
6
|
-
|
|
7
|
-
const departments = computed(() => data.value?.departments ?? [])
|
|
8
|
-
const tiers = computed(() => data.value?.tiers ?? [])
|
|
87
|
+
type View = 'category' | 'provider' | 'model'
|
|
9
88
|
|
|
10
|
-
const
|
|
89
|
+
const view = ref<View>('category')
|
|
11
90
|
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
91
|
+
const viewTabs = [
|
|
92
|
+
{ label: 'By category', value: 'category' as const },
|
|
93
|
+
{ label: 'By provider', value: 'provider' as const },
|
|
94
|
+
{ label: 'By model', value: 'model' as const },
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
const breakdownRows = computed<Array<[string, BreakdownRow]>>(() => {
|
|
98
|
+
const source = (() => {
|
|
99
|
+
switch (view.value) {
|
|
100
|
+
case 'category': return costs.value?.by_category ?? {}
|
|
101
|
+
case 'provider': return costs.value?.by_provider ?? {}
|
|
102
|
+
case 'model': return costs.value?.by_model ?? {}
|
|
103
|
+
}
|
|
104
|
+
})()
|
|
105
|
+
return Object.entries(source).sort((a, b) => {
|
|
106
|
+
const ca = a[1].total_cost_usd ?? 0
|
|
107
|
+
const cb = b[1].total_cost_usd ?? 0
|
|
108
|
+
return cb - ca
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const viewEmpty = computed(() =>
|
|
113
|
+
breakdownRows.value.length === 0
|
|
114
|
+
|| (
|
|
115
|
+
view.value === 'category'
|
|
116
|
+
&& breakdownRows.value.length === 1
|
|
117
|
+
&& breakdownRows.value[0]?.[0] === ''
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
const maxCostForBar = computed(() => {
|
|
122
|
+
const max = breakdownRows.value.reduce(
|
|
123
|
+
(acc, [, row]) => Math.max(acc, row.total_cost_usd ?? 0),
|
|
124
|
+
0,
|
|
125
|
+
)
|
|
126
|
+
return max > 0 ? max : 1
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
function formatCost(value: number | null): string {
|
|
130
|
+
if (value === null || value === undefined) return 'n/a'
|
|
131
|
+
if (value === 0) return '$0'
|
|
132
|
+
if (value < 0.01) return `$${value.toFixed(4)}`
|
|
133
|
+
return `$${value.toFixed(2)}`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function formatTokens(value: number): string {
|
|
137
|
+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
|
138
|
+
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
|
|
139
|
+
return value.toString()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function formatLabel(key: string): string {
|
|
143
|
+
if (!key) return '(base / uncategorised)'
|
|
144
|
+
return key
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── Trend chart ────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
const maxTrendCost = computed(() => {
|
|
150
|
+
if (!trend.value?.days?.length) return 1
|
|
151
|
+
const max = trend.value.days.reduce(
|
|
152
|
+
(acc, d) => Math.max(acc, d.cost_usd ?? 0),
|
|
153
|
+
0,
|
|
154
|
+
)
|
|
155
|
+
return max > 0 ? max : 1
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
function trendBarHeight(day: TrendDay): string {
|
|
159
|
+
const cost = day.cost_usd ?? 0
|
|
160
|
+
const percent = Math.min(100, (cost / maxTrendCost.value) * 100)
|
|
161
|
+
return `${Math.max(percent, day.call_count > 0 ? 4 : 0)}%`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function trendDayLabel(iso: string): string {
|
|
165
|
+
try {
|
|
166
|
+
const d = new Date(iso + 'T00:00:00Z')
|
|
167
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
168
|
+
weekday: 'short',
|
|
169
|
+
}).format(d)
|
|
170
|
+
} catch {
|
|
171
|
+
return iso
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function refreshAll() {
|
|
176
|
+
await Promise.all([refresh(), refreshTrend()])
|
|
17
177
|
}
|
|
18
178
|
</script>
|
|
19
179
|
|
|
20
180
|
<template>
|
|
21
181
|
<UDashboardPanel id="budget">
|
|
22
182
|
<template #header>
|
|
23
|
-
<UDashboardNavbar title="Usage & Budget">
|
|
183
|
+
<UDashboardNavbar title="Usage & Budget">
|
|
24
184
|
<template #leading>
|
|
25
185
|
<UDashboardSidebarCollapse />
|
|
26
186
|
</template>
|
|
27
187
|
<template #right>
|
|
188
|
+
<USelect
|
|
189
|
+
v-model="period"
|
|
190
|
+
:items="periodOptions"
|
|
191
|
+
size="sm"
|
|
192
|
+
class="w-32"
|
|
193
|
+
/>
|
|
28
194
|
<UButton
|
|
29
195
|
label="Refresh"
|
|
30
196
|
variant="ghost"
|
|
31
197
|
icon="i-lucide-refresh-cw"
|
|
32
198
|
size="sm"
|
|
33
|
-
|
|
199
|
+
class="ml-2"
|
|
200
|
+
@click="refreshAll"
|
|
34
201
|
/>
|
|
35
202
|
</template>
|
|
36
203
|
</UDashboardNavbar>
|
|
@@ -41,88 +208,173 @@ const tierLabels: Record<number, string> = {
|
|
|
41
208
|
:status="status"
|
|
42
209
|
:error="error"
|
|
43
210
|
loading-label="Loading budget"
|
|
44
|
-
:on-retry="() =>
|
|
211
|
+
:on-retry="() => refreshAll()"
|
|
45
212
|
>
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
<p class="text-xs font-semibold text-muted uppercase tracking-wider">This Month's Usage</p>
|
|
51
|
-
<div class="flex flex-wrap items-baseline gap-6">
|
|
213
|
+
<div class="space-y-6">
|
|
214
|
+
<!-- Top-line summary -->
|
|
215
|
+
<UCard>
|
|
216
|
+
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
52
217
|
<div>
|
|
53
|
-
<
|
|
54
|
-
|
|
218
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">
|
|
219
|
+
Total cost
|
|
220
|
+
</p>
|
|
221
|
+
<p class="text-2xl font-bold">
|
|
222
|
+
{{ formatCost(costs?.total_cost_usd ?? null) }}
|
|
223
|
+
</p>
|
|
55
224
|
</div>
|
|
56
225
|
<div>
|
|
57
|
-
<
|
|
58
|
-
|
|
226
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">
|
|
227
|
+
Calls
|
|
228
|
+
</p>
|
|
229
|
+
<p class="text-2xl font-bold">{{ costs?.call_count ?? 0 }}</p>
|
|
59
230
|
</div>
|
|
60
231
|
<div>
|
|
61
|
-
<
|
|
62
|
-
|
|
232
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">
|
|
233
|
+
Tokens in / out
|
|
234
|
+
</p>
|
|
235
|
+
<p class="text-lg font-semibold">
|
|
236
|
+
{{ formatTokens(costs?.total_tokens_in ?? 0) }} /
|
|
237
|
+
{{ formatTokens(costs?.total_tokens_out ?? 0) }}
|
|
238
|
+
</p>
|
|
63
239
|
</div>
|
|
64
|
-
<div
|
|
65
|
-
<
|
|
240
|
+
<div>
|
|
241
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">
|
|
242
|
+
Cache hit rate
|
|
243
|
+
</p>
|
|
244
|
+
<p class="text-2xl font-bold">
|
|
245
|
+
{{ ((costs?.cache_hit_rate ?? 0) * 100).toFixed(1) }}%
|
|
246
|
+
</p>
|
|
66
247
|
</div>
|
|
67
248
|
</div>
|
|
68
|
-
</
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
:key="dept.department"
|
|
78
|
-
class="flex items-center gap-4"
|
|
79
|
-
>
|
|
80
|
-
<span class="w-28 text-sm font-medium truncate">{{ dept.department }}</span>
|
|
81
|
-
<div class="flex-1 h-3 rounded-full bg-muted/20 overflow-hidden">
|
|
249
|
+
</UCard>
|
|
250
|
+
|
|
251
|
+
<!-- 7-day trend (inline bar chart) -->
|
|
252
|
+
<UCard v-if="trend?.days?.length">
|
|
253
|
+
<div>
|
|
254
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider mb-4">
|
|
255
|
+
Last 7 days
|
|
256
|
+
</p>
|
|
257
|
+
<div class="flex items-end gap-2 h-32">
|
|
82
258
|
<div
|
|
83
|
-
|
|
84
|
-
:
|
|
85
|
-
|
|
259
|
+
v-for="day in trend.days"
|
|
260
|
+
:key="day.date"
|
|
261
|
+
class="flex-1 flex flex-col items-center gap-1"
|
|
262
|
+
:title="`${day.date} — ${formatCost(day.cost_usd)} (${day.call_count} calls)`"
|
|
263
|
+
>
|
|
264
|
+
<div class="w-full h-full flex items-end">
|
|
265
|
+
<div
|
|
266
|
+
class="w-full rounded-t transition-none"
|
|
267
|
+
:class="day.call_count > 0 ? 'bg-primary' : 'bg-muted/20'"
|
|
268
|
+
:style="{ height: trendBarHeight(day) }"
|
|
269
|
+
/>
|
|
270
|
+
</div>
|
|
271
|
+
<span class="text-xs text-muted">{{ trendDayLabel(day.date) }}</span>
|
|
272
|
+
</div>
|
|
86
273
|
</div>
|
|
87
|
-
<span class="w-24 text-right text-sm font-mono">{{ dept.tokens.toLocaleString() }}</span>
|
|
88
|
-
<span class="w-16 text-right text-xs text-muted">{{ dept.operations }} ops</span>
|
|
89
274
|
</div>
|
|
90
|
-
</
|
|
91
|
-
</div>
|
|
92
|
-
|
|
93
|
-
<!-- Empty state -->
|
|
94
|
-
<div v-else class="flex flex-col items-center justify-center gap-4 py-12">
|
|
95
|
-
<UIcon name="i-lucide-bar-chart-3" class="size-12 text-muted" />
|
|
96
|
-
<p class="text-sm text-muted">No usage data yet.</p>
|
|
97
|
-
<p class="text-xs text-muted">Token usage is tracked automatically when ArkaOS processes prompts and indexes knowledge.</p>
|
|
98
|
-
</div>
|
|
275
|
+
</UCard>
|
|
99
276
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
class="
|
|
104
|
-
@click="showLimits = !showLimits"
|
|
277
|
+
<!-- Advisories -->
|
|
278
|
+
<UCard
|
|
279
|
+
v-if="costs?.advisories?.length"
|
|
280
|
+
class="border-yellow-500/30 bg-yellow-500/5"
|
|
105
281
|
>
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
282
|
+
<div class="flex items-start gap-3">
|
|
283
|
+
<UIcon
|
|
284
|
+
name="i-lucide-alert-triangle"
|
|
285
|
+
class="size-5 text-yellow-500 mt-0.5 shrink-0"
|
|
286
|
+
/>
|
|
287
|
+
<div>
|
|
288
|
+
<p class="text-sm font-semibold text-yellow-500 mb-2">
|
|
289
|
+
Advisories
|
|
290
|
+
</p>
|
|
291
|
+
<ul class="space-y-1 text-sm">
|
|
292
|
+
<li v-for="a in costs.advisories" :key="a">
|
|
293
|
+
{{ a }}
|
|
294
|
+
</li>
|
|
295
|
+
</ul>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
</UCard>
|
|
299
|
+
|
|
300
|
+
<!-- Breakdown views -->
|
|
301
|
+
<div>
|
|
302
|
+
<UTabs
|
|
303
|
+
v-model="view"
|
|
304
|
+
:items="viewTabs"
|
|
305
|
+
:ui="{ list: 'mb-4' }"
|
|
306
|
+
/>
|
|
307
|
+
|
|
308
|
+
<div v-if="viewEmpty" class="flex flex-col items-center justify-center gap-3 py-12 rounded-lg border border-default">
|
|
309
|
+
<UIcon name="i-lucide-bar-chart-3" class="size-10 text-muted" />
|
|
310
|
+
<p class="text-sm text-muted">
|
|
311
|
+
{{ view === 'category'
|
|
312
|
+
? "No category-aware spend yet. Orchestration layers can set ARKA_CALL_CATEGORY before LLM calls to attribute spend (PR60)."
|
|
313
|
+
: "No spend data yet for this view." }}
|
|
121
314
|
</p>
|
|
122
315
|
</div>
|
|
316
|
+
|
|
317
|
+
<div v-else class="space-y-2">
|
|
318
|
+
<div
|
|
319
|
+
v-for="[key, row] in breakdownRows"
|
|
320
|
+
:key="key"
|
|
321
|
+
class="flex items-center gap-3 rounded-lg border border-default p-3"
|
|
322
|
+
>
|
|
323
|
+
<span class="w-48 text-sm font-mono truncate" :title="formatLabel(key)">
|
|
324
|
+
{{ formatLabel(key) }}
|
|
325
|
+
</span>
|
|
326
|
+
<div class="flex-1 h-3 rounded-full bg-muted/10 overflow-hidden">
|
|
327
|
+
<div
|
|
328
|
+
class="h-3 rounded-full bg-primary"
|
|
329
|
+
:style="{
|
|
330
|
+
width: `${Math.max(2, ((row.total_cost_usd ?? 0) / maxCostForBar) * 100)}%`,
|
|
331
|
+
}"
|
|
332
|
+
/>
|
|
333
|
+
</div>
|
|
334
|
+
<span class="w-20 text-right text-sm font-mono">
|
|
335
|
+
{{ formatCost(row.total_cost_usd) }}
|
|
336
|
+
</span>
|
|
337
|
+
<span class="w-16 text-right text-xs text-muted">
|
|
338
|
+
{{ row.call_count }} calls
|
|
339
|
+
</span>
|
|
340
|
+
<span class="w-20 text-right text-xs text-muted">
|
|
341
|
+
{{ formatTokens(row.total_tokens_in + row.total_tokens_out) }} tok
|
|
342
|
+
</span>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
123
345
|
</div>
|
|
346
|
+
|
|
347
|
+
<!-- Top sessions -->
|
|
348
|
+
<UCard v-if="costs?.by_session?.length">
|
|
349
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider mb-3">
|
|
350
|
+
Top sessions
|
|
351
|
+
</p>
|
|
352
|
+
<div class="space-y-2">
|
|
353
|
+
<div
|
|
354
|
+
v-for="s in costs.by_session"
|
|
355
|
+
:key="s.session_id || 'unknown'"
|
|
356
|
+
class="flex items-center gap-3 text-sm"
|
|
357
|
+
>
|
|
358
|
+
<span class="w-48 font-mono text-xs truncate">
|
|
359
|
+
{{ s.session_id || '(unknown)' }}
|
|
360
|
+
</span>
|
|
361
|
+
<span class="flex-1 text-muted">{{ s.call_count }} calls</span>
|
|
362
|
+
<span class="w-20 text-right font-mono">{{ formatCost(s.total_cost_usd) }}</span>
|
|
363
|
+
<span class="w-20 text-right text-xs text-muted">
|
|
364
|
+
{{ formatTokens(s.total_tokens_in + s.total_tokens_out) }} tok
|
|
365
|
+
</span>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
</UCard>
|
|
369
|
+
|
|
370
|
+
<!-- Note for corrupt rows -->
|
|
371
|
+
<p
|
|
372
|
+
v-if="costs?.corrupt_line_count"
|
|
373
|
+
class="text-xs text-muted text-center"
|
|
374
|
+
>
|
|
375
|
+
Skipped {{ costs.corrupt_line_count }} corrupt JSONL line(s) in telemetry.
|
|
376
|
+
</p>
|
|
124
377
|
</div>
|
|
125
|
-
</div>
|
|
126
378
|
</DashboardState>
|
|
127
379
|
</template>
|
|
128
380
|
</UDashboardPanel>
|
|
@@ -1,26 +1,126 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
interface ProfileResponse {
|
|
3
|
+
version: string
|
|
4
|
+
name: string
|
|
5
|
+
language: string
|
|
6
|
+
market: string
|
|
7
|
+
role: string
|
|
8
|
+
company: string
|
|
9
|
+
projectsDir: string
|
|
10
|
+
vaultPath: string
|
|
11
|
+
created: string
|
|
12
|
+
updated: string
|
|
13
|
+
projects_dirs_list: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
2
16
|
const { fetchApi, apiBase } = useApi()
|
|
17
|
+
const toast = useToast()
|
|
18
|
+
|
|
19
|
+
// ─── Profile (PR63 v2.81.0) ─────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const {
|
|
22
|
+
data: profile,
|
|
23
|
+
status: profileStatus,
|
|
24
|
+
error: profileError,
|
|
25
|
+
refresh: refreshProfile,
|
|
26
|
+
} = await fetchApi<ProfileResponse>('/api/profile')
|
|
27
|
+
|
|
28
|
+
const profileDraft = ref({
|
|
29
|
+
name: profile.value?.name ?? '',
|
|
30
|
+
company: profile.value?.company ?? '',
|
|
31
|
+
role: profile.value?.role ?? '',
|
|
32
|
+
market: profile.value?.market ?? '',
|
|
33
|
+
language: profile.value?.language ?? 'en',
|
|
34
|
+
vaultPath: profile.value?.vaultPath ?? '',
|
|
35
|
+
projectsDir: profile.value?.projectsDir ?? '',
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
watch(profile, (p) => {
|
|
39
|
+
if (!p) return
|
|
40
|
+
profileDraft.value = {
|
|
41
|
+
name: p.name,
|
|
42
|
+
company: p.company,
|
|
43
|
+
role: p.role,
|
|
44
|
+
market: p.market,
|
|
45
|
+
language: p.language,
|
|
46
|
+
vaultPath: p.vaultPath,
|
|
47
|
+
projectsDir: p.projectsDir,
|
|
48
|
+
}
|
|
49
|
+
}, { immediate: true })
|
|
50
|
+
|
|
51
|
+
const savingProfile = ref(false)
|
|
52
|
+
|
|
53
|
+
async function saveProfile() {
|
|
54
|
+
savingProfile.value = true
|
|
55
|
+
try {
|
|
56
|
+
await $fetch<ProfileResponse>(`${apiBase}/api/profile`, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
body: profileDraft.value,
|
|
59
|
+
})
|
|
60
|
+
await refreshProfile()
|
|
61
|
+
toast.add({
|
|
62
|
+
title: 'Profile saved',
|
|
63
|
+
description: 'Settings written to ~/.arkaos/profile.json',
|
|
64
|
+
color: 'success',
|
|
65
|
+
})
|
|
66
|
+
} catch (err) {
|
|
67
|
+
toast.add({
|
|
68
|
+
title: 'Save failed',
|
|
69
|
+
description: err instanceof Error ? err.message : 'unknown error',
|
|
70
|
+
color: 'error',
|
|
71
|
+
})
|
|
72
|
+
} finally {
|
|
73
|
+
savingProfile.value = false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const languageOptions = [
|
|
78
|
+
{ label: 'English', value: 'en' },
|
|
79
|
+
{ label: 'Português', value: 'pt' },
|
|
80
|
+
]
|
|
3
81
|
|
|
4
|
-
const
|
|
82
|
+
const roleOptions = [
|
|
83
|
+
{ label: 'Founder', value: 'founder' },
|
|
84
|
+
{ label: 'CTO', value: 'cto' },
|
|
85
|
+
{ label: 'CEO', value: 'ceo' },
|
|
86
|
+
{ label: 'Engineer', value: 'engineer' },
|
|
87
|
+
{ label: 'Designer', value: 'designer' },
|
|
88
|
+
{ label: 'Operator', value: 'operator' },
|
|
89
|
+
{ label: 'Consultant', value: 'consultant' },
|
|
90
|
+
]
|
|
5
91
|
|
|
6
|
-
|
|
92
|
+
// ─── API Keys (preserved from earlier) ──────────────────────────────────
|
|
93
|
+
|
|
94
|
+
const {
|
|
95
|
+
data: keysData,
|
|
96
|
+
status: keysStatus,
|
|
97
|
+
refresh: refreshKeys,
|
|
98
|
+
} = fetchApi<any>('/api/keys')
|
|
99
|
+
|
|
100
|
+
const keys = computed(() => keysData.value?.keys ?? [])
|
|
7
101
|
|
|
8
102
|
const newKey = ref('')
|
|
9
103
|
const newValue = ref('')
|
|
104
|
+
const customKeyName = ref('')
|
|
10
105
|
const saving = ref(false)
|
|
11
106
|
const deletingKey = ref<string | null>(null)
|
|
12
107
|
|
|
108
|
+
const isCustom = computed(() => newKey.value === 'custom')
|
|
109
|
+
const effectiveKeyName = computed(() => isCustom.value ? customKeyName.value : newKey.value)
|
|
110
|
+
|
|
13
111
|
async function saveKey() {
|
|
14
|
-
|
|
112
|
+
const keyName = effectiveKeyName.value
|
|
113
|
+
if (!keyName || !newValue.value) return
|
|
15
114
|
saving.value = true
|
|
16
115
|
try {
|
|
17
116
|
await $fetch(`${apiBase}/api/keys`, {
|
|
18
117
|
method: 'POST',
|
|
19
|
-
body: { key:
|
|
118
|
+
body: { key: keyName, value: newValue.value },
|
|
20
119
|
})
|
|
21
120
|
newKey.value = ''
|
|
22
121
|
newValue.value = ''
|
|
23
|
-
|
|
122
|
+
customKeyName.value = ''
|
|
123
|
+
await refreshKeys()
|
|
24
124
|
} catch {}
|
|
25
125
|
saving.value = false
|
|
26
126
|
}
|
|
@@ -29,7 +129,7 @@ async function deleteKey(keyName: string) {
|
|
|
29
129
|
deletingKey.value = keyName
|
|
30
130
|
try {
|
|
31
131
|
await $fetch(`${apiBase}/api/keys/${keyName}`, { method: 'DELETE' })
|
|
32
|
-
await
|
|
132
|
+
await refreshKeys()
|
|
33
133
|
} catch {}
|
|
34
134
|
deletingKey.value = null
|
|
35
135
|
}
|
|
@@ -41,9 +141,17 @@ const keyOptions = [
|
|
|
41
141
|
{ label: 'Custom...', value: 'custom' },
|
|
42
142
|
]
|
|
43
143
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
144
|
+
// ─── Section nav ────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
type SectionId = 'profile' | 'projects' | 'keys'
|
|
147
|
+
|
|
148
|
+
const sections: { id: SectionId; label: string; icon: string }[] = [
|
|
149
|
+
{ id: 'profile', label: 'Profile', icon: 'i-lucide-user-circle' },
|
|
150
|
+
{ id: 'projects', label: 'Projects', icon: 'i-lucide-folders' },
|
|
151
|
+
{ id: 'keys', label: 'API Keys', icon: 'i-lucide-key' },
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
const activeSection = ref<SectionId>('profile')
|
|
47
155
|
</script>
|
|
48
156
|
|
|
49
157
|
<template>
|
|
@@ -57,88 +165,250 @@ const effectiveKeyName = computed(() => isCustom.value ? customKeyName.value : n
|
|
|
57
165
|
</template>
|
|
58
166
|
|
|
59
167
|
<template #body>
|
|
60
|
-
<div class="
|
|
61
|
-
<!--
|
|
168
|
+
<div class="grid grid-cols-1 md:grid-cols-[14rem_1fr] gap-6">
|
|
169
|
+
<!-- Section nav -->
|
|
170
|
+
<nav class="space-y-1" aria-label="Settings sections">
|
|
171
|
+
<button
|
|
172
|
+
v-for="s in sections"
|
|
173
|
+
:key="s.id"
|
|
174
|
+
type="button"
|
|
175
|
+
class="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm text-left transition-colors"
|
|
176
|
+
:class="activeSection === s.id
|
|
177
|
+
? 'bg-primary/10 text-primary font-medium'
|
|
178
|
+
: 'text-muted hover:bg-elevated/50'"
|
|
179
|
+
@click="activeSection = s.id"
|
|
180
|
+
>
|
|
181
|
+
<UIcon :name="s.icon" class="size-4" />
|
|
182
|
+
<span>{{ s.label }}</span>
|
|
183
|
+
</button>
|
|
184
|
+
<p class="text-xs text-muted px-3 mt-6">
|
|
185
|
+
More sections (MCPs, Hooks, Plugins, Theme) coming in PR63b.
|
|
186
|
+
</p>
|
|
187
|
+
</nav>
|
|
188
|
+
|
|
189
|
+
<!-- Section content -->
|
|
62
190
|
<div>
|
|
63
|
-
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
191
|
+
<!-- Profile -->
|
|
192
|
+
<section v-if="activeSection === 'profile'">
|
|
193
|
+
<h2 class="text-lg font-semibold mb-1">Profile</h2>
|
|
194
|
+
<p class="text-sm text-muted mb-6">
|
|
195
|
+
Your identity, role, and language. Stored locally at
|
|
196
|
+
<code class="font-mono text-xs">~/.arkaos/profile.json</code>.
|
|
197
|
+
</p>
|
|
198
|
+
|
|
199
|
+
<DashboardState
|
|
200
|
+
:status="profileStatus"
|
|
201
|
+
:error="profileError"
|
|
202
|
+
loading-label="Loading profile"
|
|
203
|
+
:on-retry="() => refreshProfile()"
|
|
204
|
+
>
|
|
205
|
+
<UCard>
|
|
206
|
+
<div class="space-y-4">
|
|
207
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
208
|
+
<UFormField label="Name">
|
|
209
|
+
<UInput
|
|
210
|
+
v-model="profileDraft.name"
|
|
211
|
+
placeholder="André Agro Ferreira"
|
|
212
|
+
class="w-full"
|
|
213
|
+
/>
|
|
214
|
+
</UFormField>
|
|
215
|
+
<UFormField label="Company">
|
|
216
|
+
<UInput
|
|
217
|
+
v-model="profileDraft.company"
|
|
218
|
+
placeholder="WizardingCode"
|
|
219
|
+
class="w-full"
|
|
220
|
+
/>
|
|
221
|
+
</UFormField>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
225
|
+
<UFormField label="Role">
|
|
226
|
+
<USelect
|
|
227
|
+
v-model="profileDraft.role"
|
|
228
|
+
:items="roleOptions"
|
|
229
|
+
placeholder="Select role"
|
|
230
|
+
class="w-full"
|
|
231
|
+
/>
|
|
232
|
+
</UFormField>
|
|
233
|
+
<UFormField label="Language">
|
|
234
|
+
<USelect
|
|
235
|
+
v-model="profileDraft.language"
|
|
236
|
+
:items="languageOptions"
|
|
237
|
+
class="w-full"
|
|
238
|
+
/>
|
|
239
|
+
</UFormField>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<UFormField
|
|
243
|
+
label="Market"
|
|
244
|
+
help="Comma-separated list of markets you operate in (free text)."
|
|
245
|
+
>
|
|
246
|
+
<UInput
|
|
247
|
+
v-model="profileDraft.market"
|
|
248
|
+
placeholder="Portugal, Europa, Emirados Árabes Unidos"
|
|
249
|
+
class="w-full"
|
|
250
|
+
/>
|
|
251
|
+
</UFormField>
|
|
252
|
+
|
|
253
|
+
<UFormField
|
|
254
|
+
label="Vault path"
|
|
255
|
+
help="Where your Obsidian vault lives. Used by the KB-first hook."
|
|
256
|
+
>
|
|
257
|
+
<UInput
|
|
258
|
+
v-model="profileDraft.vaultPath"
|
|
259
|
+
placeholder="/Users/you/Documents/Vault"
|
|
260
|
+
class="w-full font-mono text-sm"
|
|
261
|
+
/>
|
|
262
|
+
</UFormField>
|
|
263
|
+
|
|
264
|
+
<div class="flex justify-end pt-2">
|
|
265
|
+
<UButton
|
|
266
|
+
label="Save profile"
|
|
267
|
+
icon="i-lucide-check"
|
|
268
|
+
:loading="savingProfile"
|
|
269
|
+
@click="saveProfile"
|
|
270
|
+
/>
|
|
271
|
+
</div>
|
|
78
272
|
</div>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
273
|
+
</UCard>
|
|
274
|
+
</DashboardState>
|
|
275
|
+
</section>
|
|
276
|
+
|
|
277
|
+
<!-- Projects -->
|
|
278
|
+
<section v-else-if="activeSection === 'projects'">
|
|
279
|
+
<h2 class="text-lg font-semibold mb-1">Project directories</h2>
|
|
280
|
+
<p class="text-sm text-muted mb-6">
|
|
281
|
+
Directories the sync engine scans for projects.
|
|
282
|
+
Comma-separated absolute paths (e.g.
|
|
283
|
+
<code class="font-mono text-xs">~/Herd</code>,
|
|
284
|
+
<code class="font-mono text-xs">~/Work</code>).
|
|
285
|
+
</p>
|
|
286
|
+
|
|
287
|
+
<UCard>
|
|
288
|
+
<div class="space-y-4">
|
|
289
|
+
<UFormField
|
|
290
|
+
label="projectsDir"
|
|
291
|
+
help="Free text. Each comma-separated segment's leading absolute path is consumed by the sync engine."
|
|
292
|
+
>
|
|
293
|
+
<UTextarea
|
|
294
|
+
v-model="profileDraft.projectsDir"
|
|
295
|
+
:rows="3"
|
|
296
|
+
placeholder="/Users/you/Herd para projectos laravel, /Users/you/Work para projectos Nuxt e Python"
|
|
297
|
+
class="w-full font-mono text-sm"
|
|
298
|
+
/>
|
|
299
|
+
</UFormField>
|
|
300
|
+
|
|
301
|
+
<div v-if="profile?.projects_dirs_list?.length" class="rounded-lg border border-default p-3">
|
|
302
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider mb-2">
|
|
303
|
+
Currently parsed
|
|
304
|
+
</p>
|
|
305
|
+
<ul class="space-y-1">
|
|
306
|
+
<li
|
|
307
|
+
v-for="dir in profile.projects_dirs_list"
|
|
308
|
+
:key="dir"
|
|
309
|
+
class="flex items-center gap-2 text-sm"
|
|
310
|
+
>
|
|
311
|
+
<UIcon name="i-lucide-folder" class="size-4 text-muted" />
|
|
312
|
+
<code class="font-mono text-xs">{{ dir }}</code>
|
|
313
|
+
</li>
|
|
314
|
+
</ul>
|
|
82
315
|
</div>
|
|
83
|
-
|
|
316
|
+
|
|
317
|
+
<div class="flex justify-end pt-2">
|
|
84
318
|
<UButton
|
|
85
|
-
label="Save
|
|
86
|
-
icon="i-lucide-
|
|
87
|
-
:loading="
|
|
88
|
-
|
|
89
|
-
@click="() => { newKey = effectiveKeyName; saveKey() }"
|
|
90
|
-
block
|
|
319
|
+
label="Save directories"
|
|
320
|
+
icon="i-lucide-check"
|
|
321
|
+
:loading="savingProfile"
|
|
322
|
+
@click="saveProfile"
|
|
91
323
|
/>
|
|
92
324
|
</div>
|
|
93
325
|
</div>
|
|
94
|
-
</
|
|
95
|
-
</
|
|
96
|
-
|
|
97
|
-
<!-- Keys
|
|
98
|
-
<
|
|
99
|
-
<
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
class="
|
|
326
|
+
</UCard>
|
|
327
|
+
</section>
|
|
328
|
+
|
|
329
|
+
<!-- API Keys -->
|
|
330
|
+
<section v-else-if="activeSection === 'keys'">
|
|
331
|
+
<h2 class="text-lg font-semibold mb-1">API Keys</h2>
|
|
332
|
+
<p class="text-sm text-muted mb-6">
|
|
333
|
+
Configure API keys for external services. Keys are stored
|
|
334
|
+
locally at <code class="font-mono text-xs">~/.arkaos/keys.json</code>.
|
|
335
|
+
</p>
|
|
336
|
+
|
|
337
|
+
<UCard class="mb-6">
|
|
338
|
+
<div class="space-y-4">
|
|
339
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider">Add API Key</p>
|
|
340
|
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 items-end">
|
|
341
|
+
<div>
|
|
342
|
+
<label class="text-xs text-muted mb-1 block">Provider</label>
|
|
343
|
+
<USelect v-model="newKey" :items="keyOptions" class="w-full" placeholder="Select key..." />
|
|
344
|
+
</div>
|
|
345
|
+
<div v-if="isCustom">
|
|
346
|
+
<label class="text-xs text-muted mb-1 block">Key Name</label>
|
|
347
|
+
<UInput v-model="customKeyName" class="w-full" placeholder="MY_CUSTOM_KEY" />
|
|
348
|
+
</div>
|
|
349
|
+
<div :class="isCustom ? '' : 'md:col-span-1'">
|
|
350
|
+
<label class="text-xs text-muted mb-1 block">Value</label>
|
|
351
|
+
<UInput v-model="newValue" type="password" class="w-full" placeholder="sk-..." />
|
|
352
|
+
</div>
|
|
353
|
+
<div>
|
|
354
|
+
<UButton
|
|
355
|
+
label="Save Key"
|
|
356
|
+
icon="i-lucide-key"
|
|
357
|
+
:loading="saving"
|
|
358
|
+
:disabled="!effectiveKeyName || !newValue"
|
|
359
|
+
block
|
|
360
|
+
@click="saveKey"
|
|
361
|
+
/>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
</UCard>
|
|
366
|
+
|
|
367
|
+
<DashboardState
|
|
368
|
+
:status="keysStatus"
|
|
369
|
+
:empty="!keys.length"
|
|
370
|
+
empty-title="No keys configured"
|
|
371
|
+
empty-icon="i-lucide-key"
|
|
372
|
+
loading-label="Loading API keys"
|
|
107
373
|
>
|
|
108
|
-
<div class="
|
|
109
|
-
<div
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
374
|
+
<div class="space-y-2">
|
|
375
|
+
<div
|
|
376
|
+
v-for="k in keys"
|
|
377
|
+
:key="k.key"
|
|
378
|
+
class="flex items-center gap-4 p-3 rounded-lg border border-default"
|
|
379
|
+
>
|
|
380
|
+
<div class="flex-1 min-w-0">
|
|
381
|
+
<div class="flex items-center gap-2">
|
|
382
|
+
<span class="text-sm font-mono font-medium">{{ k.key }}</span>
|
|
383
|
+
<UBadge :label="k.provider" variant="subtle" size="xs" />
|
|
384
|
+
<UBadge
|
|
385
|
+
v-if="k.configured"
|
|
386
|
+
label="Configured"
|
|
387
|
+
color="success"
|
|
388
|
+
variant="subtle"
|
|
389
|
+
size="xs"
|
|
390
|
+
/>
|
|
391
|
+
<UBadge v-else label="Not Set" color="neutral" variant="outline" size="xs" />
|
|
392
|
+
</div>
|
|
393
|
+
<p v-if="k.used_for" class="text-xs text-muted mt-0.5">{{ k.used_for }}</p>
|
|
394
|
+
<p v-if="k.masked_value && k.configured" class="text-xs font-mono text-muted/60 mt-0.5">
|
|
395
|
+
{{ k.masked_value }}
|
|
396
|
+
</p>
|
|
397
|
+
</div>
|
|
398
|
+
<UButton
|
|
399
|
+
v-if="k.configured && k.masked_value !== '(from environment)'"
|
|
400
|
+
icon="i-lucide-trash-2"
|
|
401
|
+
variant="ghost"
|
|
402
|
+
color="error"
|
|
124
403
|
size="xs"
|
|
404
|
+
:loading="deletingKey === k.key"
|
|
405
|
+
aria-label="Delete key"
|
|
406
|
+
@click="deleteKey(k.key)"
|
|
125
407
|
/>
|
|
126
408
|
</div>
|
|
127
|
-
<p v-if="k.used_for" class="text-xs text-muted mt-0.5">{{ k.used_for }}</p>
|
|
128
|
-
<p v-if="k.masked_value && k.configured" class="text-xs font-mono text-muted/60 mt-0.5">{{ k.masked_value }}</p>
|
|
129
409
|
</div>
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
icon="i-lucide-trash-2"
|
|
133
|
-
variant="ghost"
|
|
134
|
-
color="error"
|
|
135
|
-
size="xs"
|
|
136
|
-
:loading="deletingKey === k.key"
|
|
137
|
-
@click="deleteKey(k.key)"
|
|
138
|
-
aria-label="Delete key"
|
|
139
|
-
/>
|
|
140
|
-
</div>
|
|
141
|
-
</div>
|
|
410
|
+
</DashboardState>
|
|
411
|
+
</section>
|
|
142
412
|
</div>
|
|
143
413
|
</div>
|
|
144
414
|
</template>
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
Binary file
|
package/scripts/dashboard-api.py
CHANGED
|
@@ -729,6 +729,139 @@ def persona_build(body: dict):
|
|
|
729
729
|
}
|
|
730
730
|
|
|
731
731
|
|
|
732
|
+
# --- LLM Costs (PR65 v2.82.0) ---
|
|
733
|
+
|
|
734
|
+
@app.get("/api/llm-costs")
|
|
735
|
+
def llm_costs(period: str = "today"):
|
|
736
|
+
"""Aggregated LLM cost summary backed by the PR47 telemetry pipeline.
|
|
737
|
+
|
|
738
|
+
`period` ∈ {today, week, month, all}. Returns the same shape the
|
|
739
|
+
`/arka costs` CLI returns — by_provider, by_model, by_category
|
|
740
|
+
(PR47), top sessions, advisories. The Budget page consumes this
|
|
741
|
+
to show category-aware spend instead of the legacy
|
|
742
|
+
/api/budget tokens-only view.
|
|
743
|
+
"""
|
|
744
|
+
try:
|
|
745
|
+
from core.runtime.llm_cost_telemetry import summarise, VALID_PERIODS
|
|
746
|
+
except Exception as exc: # pragma: no cover - import guard
|
|
747
|
+
return {"error": f"telemetry unavailable: {exc}"}
|
|
748
|
+
if period not in VALID_PERIODS:
|
|
749
|
+
return {"error": f"period must be one of {list(VALID_PERIODS)}"}
|
|
750
|
+
summary = summarise(period=period)
|
|
751
|
+
return {
|
|
752
|
+
"period": summary.period,
|
|
753
|
+
"total_cost_usd": summary.total_cost_usd,
|
|
754
|
+
"total_tokens_in": summary.total_tokens_in,
|
|
755
|
+
"total_tokens_out": summary.total_tokens_out,
|
|
756
|
+
"total_cached_tokens": summary.total_cached_tokens,
|
|
757
|
+
"cache_hit_rate": summary.cache_hit_rate,
|
|
758
|
+
"call_count": summary.call_count,
|
|
759
|
+
"by_provider": summary.by_provider,
|
|
760
|
+
"by_model": summary.by_model,
|
|
761
|
+
"by_category": summary.by_category,
|
|
762
|
+
"by_session": summary.by_session,
|
|
763
|
+
"advisories": summary.advisories,
|
|
764
|
+
"corrupt_line_count": summary.corrupt_line_count,
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
@app.get("/api/llm-costs/trend")
|
|
769
|
+
def llm_costs_trend(days: int = 7):
|
|
770
|
+
"""Day-by-day rolling totals from the cost telemetry.
|
|
771
|
+
|
|
772
|
+
Returns ``{"days": [{"date": "YYYY-MM-DD", "cost_usd": x.xx,
|
|
773
|
+
"tokens_in": N, "tokens_out": N, "call_count": N}]}`` so the
|
|
774
|
+
Budget page can render a 7-day trend chart with @unovis/vue.
|
|
775
|
+
Cap `days` to 90 to keep response size bounded.
|
|
776
|
+
"""
|
|
777
|
+
from datetime import datetime, timedelta, timezone
|
|
778
|
+
from core.runtime.llm_cost_telemetry import read_entries
|
|
779
|
+
# `days or 7` would treat 0 as "use default", which contradicts the
|
|
780
|
+
# documented floor-at-1 behaviour. Cast first, then clamp.
|
|
781
|
+
try:
|
|
782
|
+
days_int = int(days) if days is not None else 7
|
|
783
|
+
except (TypeError, ValueError):
|
|
784
|
+
days_int = 7
|
|
785
|
+
capped_days = max(1, min(days_int, 90))
|
|
786
|
+
today = datetime.now(timezone.utc).date()
|
|
787
|
+
buckets: dict[str, dict] = {}
|
|
788
|
+
# Seed every day so the chart shows zeros instead of gaps.
|
|
789
|
+
for offset in range(capped_days):
|
|
790
|
+
d = today - timedelta(days=capped_days - 1 - offset)
|
|
791
|
+
buckets[d.isoformat()] = {
|
|
792
|
+
"date": d.isoformat(),
|
|
793
|
+
"cost_usd": 0.0,
|
|
794
|
+
"cost_known": False,
|
|
795
|
+
"tokens_in": 0,
|
|
796
|
+
"tokens_out": 0,
|
|
797
|
+
"call_count": 0,
|
|
798
|
+
}
|
|
799
|
+
cutoff = today - timedelta(days=capped_days - 1)
|
|
800
|
+
for entry in read_entries():
|
|
801
|
+
raw_ts = entry.get("ts") or ""
|
|
802
|
+
if not isinstance(raw_ts, str):
|
|
803
|
+
continue
|
|
804
|
+
try:
|
|
805
|
+
ts = datetime.fromisoformat(raw_ts.replace("Z", "+00:00"))
|
|
806
|
+
except ValueError:
|
|
807
|
+
continue
|
|
808
|
+
d = ts.date()
|
|
809
|
+
if d < cutoff:
|
|
810
|
+
continue
|
|
811
|
+
key = d.isoformat()
|
|
812
|
+
if key not in buckets:
|
|
813
|
+
continue
|
|
814
|
+
b = buckets[key]
|
|
815
|
+
b["tokens_in"] += int(entry.get("tokens_in") or 0)
|
|
816
|
+
b["tokens_out"] += int(entry.get("tokens_out") or 0)
|
|
817
|
+
b["call_count"] += 1
|
|
818
|
+
cost = entry.get("estimated_cost_usd")
|
|
819
|
+
if cost is not None:
|
|
820
|
+
b["cost_usd"] += float(cost)
|
|
821
|
+
b["cost_known"] = True
|
|
822
|
+
out_days = list(buckets.values())
|
|
823
|
+
for b in out_days:
|
|
824
|
+
b["cost_usd"] = round(b["cost_usd"], 6) if b.pop("cost_known") else None
|
|
825
|
+
return {"days": out_days, "period_days": capped_days}
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
# --- Profile (PR63 v2.81.0) ---
|
|
829
|
+
|
|
830
|
+
@app.get("/api/profile")
|
|
831
|
+
def profile_get():
|
|
832
|
+
"""Return the operator profile from ~/.arkaos/profile.json.
|
|
833
|
+
|
|
834
|
+
Always returns a profile object (default empty strings when the
|
|
835
|
+
file doesn't exist yet) so the dashboard can render a setup form
|
|
836
|
+
instead of an error.
|
|
837
|
+
"""
|
|
838
|
+
from core.profile import ProfileManager
|
|
839
|
+
from core.profile.manager import parse_projects_dirs
|
|
840
|
+
profile = ProfileManager().read()
|
|
841
|
+
payload = profile.to_dict()
|
|
842
|
+
# Convenience: split projectsDir into a list for the UI.
|
|
843
|
+
payload["projects_dirs_list"] = parse_projects_dirs(profile.projectsDir)
|
|
844
|
+
return payload
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
@app.post("/api/profile")
|
|
848
|
+
def profile_post(body: dict):
|
|
849
|
+
"""Patch the operator profile.
|
|
850
|
+
|
|
851
|
+
Only the writable fields are honoured (name, language, market,
|
|
852
|
+
role, company, projectsDir, vaultPath). Unknown keys are silently
|
|
853
|
+
dropped. Returns the updated profile.
|
|
854
|
+
"""
|
|
855
|
+
if not isinstance(body, dict):
|
|
856
|
+
return {"error": "body must be an object"}
|
|
857
|
+
from core.profile import ProfileManager
|
|
858
|
+
from core.profile.manager import parse_projects_dirs
|
|
859
|
+
updated = ProfileManager().patch(body)
|
|
860
|
+
payload = updated.to_dict()
|
|
861
|
+
payload["projects_dirs_list"] = parse_projects_dirs(updated.projectsDir)
|
|
862
|
+
return payload
|
|
863
|
+
|
|
864
|
+
|
|
732
865
|
# --- API Keys ---
|
|
733
866
|
|
|
734
867
|
@app.get("/api/keys")
|