BudgetManager (budget+router), ProjectMemory (persistent memory), CodeIntelligence (symbols+git+branch policy). Updated pro-bridge.ts with all new IPC functions.
238 lines
7.8 KiB
Svelte
238 lines
7.8 KiB
Svelte
// SPDX-License-Identifier: LicenseRef-Commercial
|
|
<script lang="ts">
|
|
import {
|
|
proBudgetGet,
|
|
proBudgetSet,
|
|
proRouterRecommend,
|
|
proRouterSetProfile,
|
|
proRouterGetProfile,
|
|
type BudgetStatus,
|
|
type ModelRecommendation,
|
|
} from './pro-bridge';
|
|
|
|
interface Props {
|
|
projectId: string;
|
|
}
|
|
|
|
let { projectId }: Props = $props();
|
|
|
|
let loading = $state(true);
|
|
let error = $state<string | null>(null);
|
|
let budget = $state<BudgetStatus | null>(null);
|
|
let limitInput = $state('');
|
|
let settingLimit = $state(false);
|
|
|
|
let routerProfile = $state<string>('balanced');
|
|
let recommendation = $state<ModelRecommendation | null>(null);
|
|
|
|
const PROFILES = [
|
|
{ id: 'cost_saver', label: 'Cost Saver', desc: 'Smaller models, lower cost', icon: '$' },
|
|
{ id: 'balanced', label: 'Balanced', desc: 'Smart routing by task', icon: '~' },
|
|
{ id: 'quality_first', label: 'Quality First', desc: 'Best models always', icon: '*' },
|
|
] as const;
|
|
|
|
let barColor = $derived(
|
|
!budget ? 'var(--ctp-surface1)' :
|
|
budget.percent > 90 ? 'var(--ctp-red)' :
|
|
budget.percent > 70 ? 'var(--ctp-yellow)' :
|
|
'var(--ctp-green)'
|
|
);
|
|
|
|
async function loadAll() {
|
|
loading = true;
|
|
error = null;
|
|
try {
|
|
const [b, profile] = await Promise.all([
|
|
proBudgetGet(projectId),
|
|
proRouterGetProfile(projectId),
|
|
]);
|
|
budget = b;
|
|
routerProfile = profile;
|
|
limitInput = String(b.limit);
|
|
recommendation = await proRouterRecommend(projectId, 'default', 4000, 'claude');
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : String(e);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
void projectId;
|
|
loadAll();
|
|
});
|
|
|
|
async function setLimit() {
|
|
const val = parseInt(limitInput, 10);
|
|
if (isNaN(val) || val <= 0) return;
|
|
settingLimit = true;
|
|
try {
|
|
await proBudgetSet(projectId, val);
|
|
budget = await proBudgetGet(projectId);
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : String(e);
|
|
} finally {
|
|
settingLimit = false;
|
|
}
|
|
}
|
|
|
|
async function selectProfile(id: string) {
|
|
try {
|
|
await proRouterSetProfile(projectId, id);
|
|
routerProfile = id;
|
|
recommendation = await proRouterRecommend(projectId, 'default', 4000, 'claude');
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : String(e);
|
|
}
|
|
}
|
|
|
|
function fmtK(n: number): string {
|
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
return String(n);
|
|
}
|
|
</script>
|
|
|
|
<div class="budget-root">
|
|
{#if loading}
|
|
<div class="state-msg">Loading budget data...</div>
|
|
{:else if error}
|
|
<div class="state-msg error">{error}</div>
|
|
{:else}
|
|
<!-- Budget Section -->
|
|
<div class="section-title">Budget Governor</div>
|
|
{#if budget}
|
|
<div class="budget-bar-wrap">
|
|
<div class="budget-bar-bg">
|
|
<div class="budget-bar-fill" style:width="{Math.min(budget.percent, 100)}%" style:background={barColor}></div>
|
|
</div>
|
|
<div class="budget-stats">
|
|
<span>{fmtK(budget.used)} / {fmtK(budget.limit)} tokens</span>
|
|
<span class="budget-pct" style:color={barColor}>{budget.percent.toFixed(1)}%</span>
|
|
</div>
|
|
<div class="budget-meta">
|
|
<span>Remaining: {fmtK(budget.remaining)}</span>
|
|
<span>Resets: {budget.resetDate}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="limit-row">
|
|
<input
|
|
class="limit-input"
|
|
type="number"
|
|
bind:value={limitInput}
|
|
placeholder="Monthly token limit"
|
|
/>
|
|
<button class="btn" onclick={setLimit} disabled={settingLimit}>
|
|
{settingLimit ? 'Setting...' : 'Set Limit'}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Router Section -->
|
|
<div class="section-title" style:margin-top="0.75rem">Smart Model Router</div>
|
|
<div class="profile-cards">
|
|
{#each PROFILES as p}
|
|
<button
|
|
class="profile-card"
|
|
class:active={routerProfile === p.id}
|
|
onclick={() => selectProfile(p.id)}
|
|
>
|
|
<span class="profile-icon">{p.icon}</span>
|
|
<span class="profile-label">{p.label}</span>
|
|
<span class="profile-desc">{p.desc}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
|
|
{#if recommendation}
|
|
<div class="rec-box">
|
|
<span class="rec-label">Recommended:</span>
|
|
<span class="rec-model">{recommendation.model}</span>
|
|
<span class="rec-reason">{recommendation.reason}</span>
|
|
<span class="rec-cost">Cost factor: {recommendation.estimatedCostFactor.toFixed(2)}x</span>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.budget-root {
|
|
padding: 0.75rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
color: var(--ctp-text);
|
|
font-family: var(--ui-font-family, system-ui, sans-serif);
|
|
font-size: var(--ui-font-size, 0.8125rem);
|
|
overflow-y: auto;
|
|
height: 100%;
|
|
}
|
|
.state-msg { padding: 1.5rem; text-align: center; color: var(--ctp-subtext0); }
|
|
.state-msg.error { color: var(--ctp-red); }
|
|
.section-title {
|
|
font-size: 0.75rem; color: var(--ctp-subtext1);
|
|
text-transform: uppercase; letter-spacing: 0.04em;
|
|
}
|
|
.budget-bar-wrap {
|
|
background: var(--ctp-surface0); border-radius: 0.375rem;
|
|
border: 1px solid var(--ctp-surface1); padding: 0.625rem;
|
|
display: flex; flex-direction: column; gap: 0.375rem;
|
|
}
|
|
.budget-bar-bg {
|
|
height: 0.5rem; background: var(--ctp-surface1);
|
|
border-radius: 0.25rem; overflow: hidden;
|
|
}
|
|
.budget-bar-fill {
|
|
height: 100%; border-radius: 0.25rem;
|
|
transition: width 0.3s ease, background 0.3s ease;
|
|
}
|
|
.budget-stats {
|
|
display: flex; justify-content: space-between;
|
|
font-size: 0.75rem; color: var(--ctp-subtext0);
|
|
}
|
|
.budget-pct { font-weight: 600; }
|
|
.budget-meta {
|
|
display: flex; justify-content: space-between;
|
|
font-size: 0.6875rem; color: var(--ctp-overlay0);
|
|
}
|
|
.limit-row { display: flex; gap: 0.375rem; }
|
|
.limit-input {
|
|
flex: 1; padding: 0.3125rem 0.5rem; border-radius: 0.25rem;
|
|
border: 1px solid var(--ctp-surface1); background: var(--ctp-surface0);
|
|
color: var(--ctp-text); font-size: 0.75rem;
|
|
}
|
|
.limit-input:focus { outline: none; border-color: var(--ctp-blue); }
|
|
.btn {
|
|
padding: 0.3125rem 0.75rem; border-radius: 0.25rem;
|
|
border: 1px solid var(--ctp-surface1); background: var(--ctp-blue);
|
|
color: var(--ctp-base); cursor: pointer; font-size: 0.75rem; font-weight: 500;
|
|
}
|
|
.btn:hover { opacity: 0.9; }
|
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
.profile-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.375rem; }
|
|
.profile-card {
|
|
display: flex; flex-direction: column; align-items: center;
|
|
gap: 0.25rem; padding: 0.5rem; border-radius: 0.375rem;
|
|
border: 1px solid var(--ctp-surface1); background: var(--ctp-surface0);
|
|
cursor: pointer; transition: border-color 0.15s;
|
|
}
|
|
.profile-card:hover { border-color: var(--ctp-blue); }
|
|
.profile-card.active {
|
|
border-color: var(--ctp-blue);
|
|
background: color-mix(in srgb, var(--ctp-blue) 10%, var(--ctp-surface0));
|
|
}
|
|
.profile-icon { font-size: 1.25rem; font-weight: 700; color: var(--ctp-blue); }
|
|
.profile-label { font-size: 0.75rem; font-weight: 600; color: var(--ctp-text); }
|
|
.profile-desc { font-size: 0.625rem; color: var(--ctp-subtext0); text-align: center; }
|
|
.rec-box {
|
|
display: flex; flex-wrap: wrap; gap: 0.375rem; align-items: baseline;
|
|
padding: 0.5rem 0.625rem; background: var(--ctp-surface0);
|
|
border-radius: 0.375rem; border: 1px solid var(--ctp-surface1);
|
|
font-size: 0.75rem;
|
|
}
|
|
.rec-label { color: var(--ctp-subtext0); }
|
|
.rec-model { font-family: monospace; color: var(--ctp-blue); font-weight: 600; }
|
|
.rec-reason { color: var(--ctp-subtext1); flex-basis: 100%; }
|
|
.rec-cost { color: var(--ctp-overlay0); font-size: 0.6875rem; }
|
|
</style>
|