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:
Hibryda 2026-03-17 03:27:40 +01:00
parent 191b869b43
commit be084c8f17
4 changed files with 826 additions and 0 deletions

View 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>