feat(pro): add Svelte components for commercial phases
BudgetManager (budget+router), ProjectMemory (persistent memory), CodeIntelligence (symbols+git+branch policy). Updated pro-bridge.ts with all new IPC functions.
This commit is contained in:
parent
191b869b43
commit
be084c8f17
4 changed files with 826 additions and 0 deletions
238
src/lib/commercial/BudgetManager.svelte
Normal file
238
src/lib/commercial/BudgetManager.svelte
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
// 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue