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>
|
||||
281
src/lib/commercial/CodeIntelligence.svelte
Normal file
281
src/lib/commercial/CodeIntelligence.svelte
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
// SPDX-License-Identifier: LicenseRef-Commercial
|
||||
<script lang="ts">
|
||||
import {
|
||||
proGitContext,
|
||||
proGitInject,
|
||||
proBranchCheck,
|
||||
proSymbolsScan,
|
||||
proSymbolsSearch,
|
||||
type GitContext,
|
||||
type PolicyDecision,
|
||||
type Symbol,
|
||||
} from './pro-bridge';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
let { projectId, projectPath }: Props = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Git
|
||||
let gitCtx = $state<GitContext | null>(null);
|
||||
let gitInjected = $state<string | null>(null);
|
||||
let injecting = $state(false);
|
||||
|
||||
// Branch policy
|
||||
let policy = $state<PolicyDecision | null>(null);
|
||||
|
||||
// Symbols
|
||||
let scanResult = $state<{ filesScanned: number; symbolsFound: number; durationMs: number } | null>(null);
|
||||
let scanning = $state(false);
|
||||
let symbolQuery = $state('');
|
||||
let symbols = $state<Symbol[]>([]);
|
||||
let searchingSymbols = $state(false);
|
||||
|
||||
const KIND_COLORS: Record<string, string> = {
|
||||
function: 'var(--ctp-blue)', class: 'var(--ctp-mauve)', interface: 'var(--ctp-teal)',
|
||||
type: 'var(--ctp-green)', const: 'var(--ctp-peach)', variable: 'var(--ctp-yellow)',
|
||||
enum: 'var(--ctp-pink)', method: 'var(--ctp-sapphire)', struct: 'var(--ctp-flamingo)',
|
||||
};
|
||||
|
||||
async function loadAll() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const [g, p] = await Promise.all([
|
||||
proGitContext(projectPath),
|
||||
proBranchCheck(projectPath),
|
||||
]);
|
||||
gitCtx = g;
|
||||
policy = p;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void projectPath;
|
||||
loadAll();
|
||||
});
|
||||
|
||||
async function injectGit() {
|
||||
injecting = true;
|
||||
try {
|
||||
gitInjected = await proGitInject(projectPath, 4000);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
injecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function scanSymbols() {
|
||||
scanning = true;
|
||||
try {
|
||||
scanResult = await proSymbolsScan(projectPath);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
scanning = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchSymbols() {
|
||||
if (!symbolQuery.trim()) { symbols = []; return; }
|
||||
searchingSymbols = true;
|
||||
try {
|
||||
symbols = await proSymbolsSearch(projectPath, symbolQuery.trim());
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
searchingSymbols = false;
|
||||
}
|
||||
}
|
||||
|
||||
function shortHash(h: string): string { return h.slice(0, 7); }
|
||||
function shortPath(p: string): string {
|
||||
const parts = p.split('/');
|
||||
return parts.length > 3 ? '.../' + parts.slice(-2).join('/') : p;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ci-root">
|
||||
{#if loading}
|
||||
<div class="state-msg">Loading code intelligence...</div>
|
||||
{:else if error}
|
||||
<div class="state-msg error">{error}</div>
|
||||
{:else}
|
||||
<!-- Git Context -->
|
||||
<div class="section">
|
||||
<div class="section-title">Git Context</div>
|
||||
{#if gitCtx}
|
||||
<div class="git-header">
|
||||
<span class="branch-badge">{gitCtx.branch}</span>
|
||||
{#if gitCtx.hasUnstaged}<span class="unstaged-badge">unstaged changes</span>{/if}
|
||||
<button class="btn" onclick={injectGit} disabled={injecting}>
|
||||
{injecting ? 'Injecting...' : 'Inject'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if gitCtx.lastCommits.length > 0}
|
||||
<div class="commit-list">
|
||||
{#each gitCtx.lastCommits.slice(0, 5) as c}
|
||||
<div class="commit-row"><span class="commit-hash">{shortHash(c.hash)}</span><span class="commit-msg">{c.message}</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if gitCtx.modifiedFiles.length > 0}
|
||||
<div class="modified-files">
|
||||
<span class="file-count">{gitCtx.modifiedFiles.length} modified</span>
|
||||
{#each gitCtx.modifiedFiles.slice(0, 8) as f}<span class="file-path">{shortPath(f)}</span>{/each}
|
||||
{#if gitCtx.modifiedFiles.length > 8}<span class="file-more">+{gitCtx.modifiedFiles.length - 8} more</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if gitInjected}
|
||||
<pre class="inject-preview">{gitInjected}</pre>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Branch Policy -->
|
||||
<div class="section">
|
||||
<div class="section-title">Branch Policy</div>
|
||||
{#if policy}
|
||||
<div class="policy-box" class:allowed={policy.allowed} class:blocked={!policy.allowed}>
|
||||
<span class="policy-status">{policy.allowed ? 'Allowed' : 'Blocked'}</span>
|
||||
<span class="policy-branch">{policy.branch}</span>
|
||||
{#if policy.matchedPolicy}
|
||||
<span class="policy-match">Policy: {policy.matchedPolicy}</span>
|
||||
{/if}
|
||||
<span class="policy-reason">{policy.reason}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Symbol Graph -->
|
||||
<div class="section">
|
||||
<div class="section-title">Symbol Graph</div>
|
||||
<div class="symbol-toolbar">
|
||||
<button class="btn secondary" onclick={scanSymbols} disabled={scanning}>
|
||||
{scanning ? 'Scanning...' : 'Scan Project'}
|
||||
</button>
|
||||
{#if scanResult}
|
||||
<span class="scan-stats">
|
||||
{scanResult.filesScanned} files, {scanResult.symbolsFound} symbols ({scanResult.durationMs}ms)
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="search-row">
|
||||
<input
|
||||
class="search-input"
|
||||
type="text"
|
||||
bind:value={symbolQuery}
|
||||
placeholder="Search symbols..."
|
||||
onkeydown={(e: KeyboardEvent) => e.key === 'Enter' && searchSymbols()}
|
||||
/>
|
||||
<button class="btn" onclick={searchSymbols} disabled={searchingSymbols}>
|
||||
{searchingSymbols ? '...' : 'Find'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if symbols.length > 0}
|
||||
<div class="symbol-list">
|
||||
{#each symbols as sym}
|
||||
<div class="symbol-row">
|
||||
<span class="kind-badge" style:background={KIND_COLORS[sym.kind] ?? 'var(--ctp-overlay0)'}>{sym.kind}</span>
|
||||
<span class="symbol-name">{sym.name}</span>
|
||||
<span class="symbol-loc">{shortPath(sym.filePath)}:{sym.lineNumber}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ci-root {
|
||||
padding: 0.75rem; display: flex; flex-direction: column; gap: 0.75rem;
|
||||
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 { display: flex; flex-direction: column; gap: 0.375rem; }
|
||||
.section-title { font-size: 0.75rem; color: var(--ctp-subtext1); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.git-header, .symbol-toolbar { display: flex; align-items: center; gap: 0.375rem; flex-wrap: wrap; }
|
||||
.branch-badge {
|
||||
padding: 0.125rem 0.5rem; border-radius: 0.25rem; background: var(--ctp-mauve);
|
||||
color: var(--ctp-base); font-family: monospace; font-size: 0.75rem; font-weight: 600;
|
||||
}
|
||||
.unstaged-badge {
|
||||
padding: 0.0625rem 0.375rem; border-radius: 0.1875rem; background: var(--ctp-yellow);
|
||||
color: var(--ctp-base); font-size: 0.625rem; font-weight: 600; text-transform: uppercase;
|
||||
}
|
||||
.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; margin-left: auto;
|
||||
}
|
||||
.btn:hover { opacity: 0.9; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn.secondary { background: var(--ctp-surface0); color: var(--ctp-subtext0); margin-left: 0; }
|
||||
.btn.secondary:hover { background: var(--ctp-surface1); color: var(--ctp-text); }
|
||||
.commit-list, .symbol-list {
|
||||
display: flex; flex-direction: column; gap: 0.125rem; padding: 0.375rem;
|
||||
background: var(--ctp-surface0); border-radius: 0.375rem; border: 1px solid var(--ctp-surface1);
|
||||
}
|
||||
.symbol-list { max-height: 15rem; overflow-y: auto; }
|
||||
.commit-row { display: flex; gap: 0.375rem; align-items: baseline; font-size: 0.75rem; }
|
||||
.commit-hash { font-family: monospace; color: var(--ctp-peach); font-size: 0.6875rem; flex-shrink: 0; }
|
||||
.commit-msg { color: var(--ctp-subtext1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.modified-files { display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; font-size: 0.6875rem; }
|
||||
.file-count { color: var(--ctp-yellow); font-weight: 600; margin-right: 0.25rem; }
|
||||
.file-path {
|
||||
padding: 0.0625rem 0.3125rem; border-radius: 0.1875rem; background: var(--ctp-surface0);
|
||||
color: var(--ctp-subtext0); font-family: monospace; font-size: 0.625rem;
|
||||
}
|
||||
.file-more { color: var(--ctp-overlay0); }
|
||||
.inject-preview {
|
||||
font-family: monospace; font-size: 0.6875rem; color: var(--ctp-subtext0);
|
||||
background: var(--ctp-surface0); border-radius: 0.375rem; border: 1px solid var(--ctp-teal);
|
||||
padding: 0.375rem; white-space: pre-wrap; word-break: break-word; max-height: 8rem; overflow-y: auto; margin: 0;
|
||||
}
|
||||
.policy-box {
|
||||
display: flex; flex-wrap: wrap; gap: 0.375rem; align-items: center;
|
||||
padding: 0.5rem 0.625rem; border-radius: 0.375rem; border: 1px solid var(--ctp-surface1); font-size: 0.75rem;
|
||||
}
|
||||
.policy-box.allowed { background: color-mix(in srgb, var(--ctp-green) 8%, var(--ctp-surface0)); border-color: var(--ctp-green); }
|
||||
.policy-box.blocked { background: color-mix(in srgb, var(--ctp-red) 8%, var(--ctp-surface0)); border-color: var(--ctp-red); }
|
||||
.policy-status { font-weight: 700; }
|
||||
.policy-box.allowed .policy-status { color: var(--ctp-green); }
|
||||
.policy-box.blocked .policy-status { color: var(--ctp-red); }
|
||||
.policy-branch { font-family: monospace; color: var(--ctp-mauve); }
|
||||
.policy-match, .policy-reason, .scan-stats { color: var(--ctp-overlay0); font-size: 0.6875rem; }
|
||||
.policy-reason { flex-basis: 100%; color: var(--ctp-subtext0); }
|
||||
.search-row { display: flex; gap: 0.375rem; }
|
||||
.search-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;
|
||||
}
|
||||
.search-input:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
.symbol-row {
|
||||
display: flex; gap: 0.375rem; align-items: center; font-size: 0.75rem;
|
||||
padding: 0.1875rem 0.25rem; border-radius: 0.1875rem;
|
||||
}
|
||||
.symbol-row:hover { background: var(--ctp-surface1); }
|
||||
.kind-badge {
|
||||
padding: 0.0625rem 0.3125rem; border-radius: 0.1875rem; font-size: 0.5625rem;
|
||||
font-weight: 600; color: var(--ctp-base); text-transform: uppercase; letter-spacing: 0.03em; flex-shrink: 0;
|
||||
}
|
||||
.symbol-name { font-family: monospace; color: var(--ctp-text); font-weight: 500; }
|
||||
.symbol-loc { font-family: monospace; font-size: 0.625rem; color: var(--ctp-overlay0); margin-left: auto; flex-shrink: 0; }
|
||||
</style>
|
||||
267
src/lib/commercial/ProjectMemory.svelte
Normal file
267
src/lib/commercial/ProjectMemory.svelte
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
// SPDX-License-Identifier: LicenseRef-Commercial
|
||||
<script lang="ts">
|
||||
import {
|
||||
proMemoryList,
|
||||
proMemorySearch,
|
||||
proMemoryAdd,
|
||||
proMemoryDelete,
|
||||
proMemoryInject,
|
||||
type MemoryFragment,
|
||||
} from './pro-bridge';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
let { projectId }: Props = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let memories = $state<MemoryFragment[]>([]);
|
||||
let searchQuery = $state('');
|
||||
let searching = $state(false);
|
||||
|
||||
// Add form
|
||||
let showAddForm = $state(false);
|
||||
let addContent = $state('');
|
||||
let addTags = $state('');
|
||||
let addSource = $state<'human' | 'agent' | 'auto'>('human');
|
||||
let adding = $state(false);
|
||||
|
||||
// Inject
|
||||
let injectedPreview = $state<string | null>(null);
|
||||
let injecting = $state(false);
|
||||
|
||||
const TRUST_COLORS: Record<string, string> = {
|
||||
human: 'var(--ctp-green)',
|
||||
agent: 'var(--ctp-yellow)',
|
||||
auto: 'var(--ctp-blue)',
|
||||
};
|
||||
|
||||
async function loadMemories() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
memories = await proMemoryList(projectId, 50);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void projectId;
|
||||
loadMemories();
|
||||
});
|
||||
|
||||
async function doSearch() {
|
||||
if (!searchQuery.trim()) { loadMemories(); return; }
|
||||
searching = true;
|
||||
error = null;
|
||||
try {
|
||||
memories = await proMemorySearch(projectId, searchQuery.trim());
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addMemory() {
|
||||
if (!addContent.trim()) return;
|
||||
adding = true;
|
||||
try {
|
||||
await proMemoryAdd(projectId, addContent.trim(), addSource, addTags.trim());
|
||||
addContent = '';
|
||||
addTags = '';
|
||||
showAddForm = false;
|
||||
await loadMemories();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMemory(id: number) {
|
||||
try {
|
||||
await proMemoryDelete(id);
|
||||
memories = memories.filter((m) => m.id !== id);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function injectContext() {
|
||||
injecting = true;
|
||||
try {
|
||||
injectedPreview = await proMemoryInject(projectId, 4000);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
injecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDate(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="memory-root">
|
||||
<div class="toolbar">
|
||||
<div class="search-row">
|
||||
<input
|
||||
class="search-input"
|
||||
type="text"
|
||||
placeholder="Search memories..."
|
||||
bind:value={searchQuery}
|
||||
onkeydown={(e: KeyboardEvent) => e.key === 'Enter' && doSearch()}
|
||||
/>
|
||||
<button class="btn" onclick={doSearch} disabled={searching}>
|
||||
{searching ? '...' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button class="btn" onclick={() => (showAddForm = !showAddForm)}>
|
||||
{showAddForm ? 'Cancel' : '+ Add Memory'}
|
||||
</button>
|
||||
<button class="btn secondary" onclick={injectContext} disabled={injecting}>
|
||||
{injecting ? 'Injecting...' : 'Inject Context'}
|
||||
</button>
|
||||
<button class="btn secondary" disabled>Extract from Session</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showAddForm}
|
||||
<div class="add-form">
|
||||
<textarea class="add-textarea" bind:value={addContent} placeholder="Memory content..." rows="3"></textarea>
|
||||
<div class="add-row">
|
||||
<input class="add-tags" type="text" bind:value={addTags} placeholder="Tags (comma-separated)" />
|
||||
<select class="add-select" bind:value={addSource}>
|
||||
<option value="human">Human</option>
|
||||
<option value="agent">Agent</option>
|
||||
<option value="auto">Auto</option>
|
||||
</select>
|
||||
<button class="btn" onclick={addMemory} disabled={adding || !addContent.trim()}>
|
||||
{adding ? 'Adding...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if injectedPreview}
|
||||
<div class="inject-preview">
|
||||
<div class="inject-header">
|
||||
<span class="section-title">Injected Context Preview</span>
|
||||
<button class="close-btn" onclick={() => (injectedPreview = null)}>x</button>
|
||||
</div>
|
||||
<pre class="inject-text">{injectedPreview}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="state-msg error">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="state-msg">Loading memories...</div>
|
||||
{:else if memories.length === 0}
|
||||
<div class="state-msg">No memories found.</div>
|
||||
{:else}
|
||||
<div class="memory-list">
|
||||
{#each memories as mem (mem.id)}
|
||||
<div class="memory-card">
|
||||
<div class="card-header">
|
||||
<span class="trust-badge" style:background={TRUST_COLORS[mem.trust] ?? 'var(--ctp-overlay0)'}>{mem.source}</span>
|
||||
<span class="card-date">{fmtDate(mem.createdAt)}</span>
|
||||
<button class="del-btn" onclick={() => removeMemory(mem.id)}>x</button>
|
||||
</div>
|
||||
<div class="card-content">{mem.content.length > 200 ? mem.content.slice(0, 200) + '...' : mem.content}</div>
|
||||
<div class="card-footer">
|
||||
<div class="confidence-bar-bg">
|
||||
<div class="confidence-bar-fill" style:width="{mem.confidence * 100}%"></div>
|
||||
</div>
|
||||
{#if mem.tags}
|
||||
<div class="card-tags">
|
||||
{#each mem.tags.split(',') as tag}
|
||||
<span class="tag">{tag.trim()}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.memory-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%;
|
||||
}
|
||||
.toolbar { display: flex; flex-direction: column; gap: 0.375rem; }
|
||||
.search-row { display: flex; gap: 0.375rem; }
|
||||
.search-input, .add-tags {
|
||||
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;
|
||||
}
|
||||
.search-input:focus, .add-tags:focus, .add-textarea:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
.action-row { display: flex; gap: 0.375rem; flex-wrap: wrap; }
|
||||
.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; }
|
||||
.btn.secondary { background: var(--ctp-surface0); color: var(--ctp-subtext0); }
|
||||
.btn.secondary:hover { background: var(--ctp-surface1); color: var(--ctp-text); }
|
||||
.add-form {
|
||||
display: flex; flex-direction: column; gap: 0.375rem; padding: 0.5rem;
|
||||
background: var(--ctp-surface0); border-radius: 0.375rem; border: 1px solid var(--ctp-surface1);
|
||||
}
|
||||
.add-textarea {
|
||||
padding: 0.375rem; border-radius: 0.25rem; border: 1px solid var(--ctp-surface1);
|
||||
background: var(--ctp-mantle); color: var(--ctp-text); font-size: 0.75rem; resize: vertical; font-family: inherit;
|
||||
}
|
||||
.add-row { display: flex; gap: 0.375rem; }
|
||||
.add-select {
|
||||
padding: 0.25rem 0.375rem; border-radius: 0.25rem;
|
||||
border: 1px solid var(--ctp-surface1); background: var(--ctp-mantle); color: var(--ctp-text); font-size: 0.75rem;
|
||||
}
|
||||
.inject-preview { padding: 0.5rem; background: var(--ctp-surface0); border-radius: 0.375rem; border: 1px solid var(--ctp-teal); }
|
||||
.inject-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.375rem; }
|
||||
.section-title { font-size: 0.75rem; color: var(--ctp-subtext1); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.close-btn { background: none; border: none; color: var(--ctp-overlay0); cursor: pointer; font-size: 0.75rem; padding: 0.125rem 0.25rem; }
|
||||
.close-btn:hover { color: var(--ctp-text); }
|
||||
.inject-text {
|
||||
font-family: monospace; font-size: 0.6875rem; color: var(--ctp-subtext0);
|
||||
white-space: pre-wrap; word-break: break-word; max-height: 10rem; overflow-y: auto; margin: 0;
|
||||
}
|
||||
.state-msg { padding: 1.5rem; text-align: center; color: var(--ctp-subtext0); }
|
||||
.state-msg.error { color: var(--ctp-red); }
|
||||
.memory-list { display: flex; flex-direction: column; gap: 0.375rem; }
|
||||
.memory-card {
|
||||
padding: 0.5rem 0.625rem; background: var(--ctp-surface0); border-radius: 0.375rem;
|
||||
border: 1px solid var(--ctp-surface1); display: flex; flex-direction: column; gap: 0.375rem;
|
||||
}
|
||||
.memory-card:hover { border-color: var(--ctp-surface2); }
|
||||
.card-header { display: flex; align-items: center; gap: 0.375rem; }
|
||||
.trust-badge, .tag, .kind-badge {
|
||||
padding: 0.0625rem 0.375rem; border-radius: 0.1875rem; font-size: 0.625rem; font-weight: 600;
|
||||
}
|
||||
.trust-badge { color: var(--ctp-base); text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.card-date { font-size: 0.6875rem; color: var(--ctp-overlay0); margin-left: auto; }
|
||||
.del-btn { background: none; border: none; color: var(--ctp-overlay0); cursor: pointer; font-size: 0.75rem; padding: 0 0.25rem; }
|
||||
.del-btn:hover { color: var(--ctp-red); }
|
||||
.card-content { font-size: 0.75rem; color: var(--ctp-subtext1); line-height: 1.4; }
|
||||
.card-footer { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.confidence-bar-bg { height: 0.1875rem; background: var(--ctp-surface1); border-radius: 0.125rem; overflow: hidden; }
|
||||
.confidence-bar-fill { height: 100%; background: var(--ctp-teal); border-radius: 0.125rem; }
|
||||
.card-tags { display: flex; flex-wrap: wrap; gap: 0.25rem; }
|
||||
.tag { background: var(--ctp-surface1); color: var(--ctp-subtext0); font-weight: 400; }
|
||||
</style>
|
||||
|
|
@ -150,3 +150,43 @@ export const proMarketplaceUpdate = (pluginId: string) =>
|
|||
|
||||
export const proStatus = () =>
|
||||
invoke<string>('plugin:agor-pro|pro_status');
|
||||
|
||||
// --- Budget Governor ---
|
||||
|
||||
export interface BudgetStatus { limit: number; used: number; remaining: number; percent: number; resetDate: string; }
|
||||
export interface BudgetDecision { allowed: boolean; reason: string; remaining: number; }
|
||||
export const proBudgetSet = (projectId: string, monthlyLimitTokens: number) => invoke<void>('plugin:agor-pro|pro_budget_set', { projectId, monthlyLimitTokens });
|
||||
export const proBudgetGet = (projectId: string) => invoke<BudgetStatus>('plugin:agor-pro|pro_budget_get', { projectId });
|
||||
export const proBudgetCheck = (projectId: string, estimatedTokens: number) => invoke<BudgetDecision>('plugin:agor-pro|pro_budget_check', { projectId, estimatedTokens });
|
||||
export const proBudgetLogUsage = (projectId: string, sessionId: string, tokensUsed: number) => invoke<void>('plugin:agor-pro|pro_budget_log_usage', { projectId, sessionId, tokensUsed });
|
||||
export const proBudgetList = () => invoke<Array<BudgetStatus & { projectId: string }>>('plugin:agor-pro|pro_budget_list');
|
||||
|
||||
// --- Smart Model Router ---
|
||||
|
||||
export interface ModelRecommendation { model: string; reason: string; estimatedCostFactor: number; }
|
||||
export const proRouterRecommend = (projectId: string, role: string, promptLength: number, provider: string) => invoke<ModelRecommendation>('plugin:agor-pro|pro_router_recommend', { projectId, role, promptLength, provider });
|
||||
export const proRouterSetProfile = (projectId: string, profile: string) => invoke<void>('plugin:agor-pro|pro_router_set_profile', { projectId, profile });
|
||||
export const proRouterGetProfile = (projectId: string) => invoke<string>('plugin:agor-pro|pro_router_get_profile', { projectId });
|
||||
|
||||
// --- Persistent Memory ---
|
||||
|
||||
export interface MemoryFragment { id: number; projectId: string; content: string; source: string; trust: string; confidence: number; createdAt: number; ttlDays: number; tags: string; }
|
||||
export const proMemoryAdd = (projectId: string, content: string, source: string, tags: string) => invoke<number>('plugin:agor-pro|pro_memory_add', { projectId, content, source, tags });
|
||||
export const proMemoryList = (projectId: string, limit: number) => invoke<MemoryFragment[]>('plugin:agor-pro|pro_memory_list', { projectId, limit });
|
||||
export const proMemorySearch = (projectId: string, query: string) => invoke<MemoryFragment[]>('plugin:agor-pro|pro_memory_search', { projectId, query });
|
||||
export const proMemoryDelete = (id: number) => invoke<void>('plugin:agor-pro|pro_memory_delete', { id });
|
||||
export const proMemoryInject = (projectId: string, maxTokens: number) => invoke<string>('plugin:agor-pro|pro_memory_inject', { projectId, maxTokens });
|
||||
|
||||
// --- Git Context ---
|
||||
|
||||
export interface GitContext { branch: string; lastCommits: Array<{hash: string; message: string; author: string; timestamp: number}>; modifiedFiles: string[]; hasUnstaged: boolean; }
|
||||
export interface PolicyDecision { allowed: boolean; branch: string; matchedPolicy: string | null; reason: string; }
|
||||
export const proGitContext = (projectPath: string) => invoke<GitContext>('plugin:agor-pro|pro_git_context', { projectPath });
|
||||
export const proGitInject = (projectPath: string, maxTokens: number) => invoke<string>('plugin:agor-pro|pro_git_inject', { projectPath, maxTokens });
|
||||
export const proBranchCheck = (projectPath: string) => invoke<PolicyDecision>('plugin:agor-pro|pro_branch_check', { projectPath });
|
||||
|
||||
// --- Symbols ---
|
||||
|
||||
export interface Symbol { name: string; kind: string; filePath: string; lineNumber: number; }
|
||||
export const proSymbolsScan = (projectPath: string) => invoke<{filesScanned: number; symbolsFound: number; durationMs: number}>('plugin:agor-pro|pro_symbols_scan', { projectPath });
|
||||
export const proSymbolsSearch = (projectPath: string, query: string) => invoke<Symbol[]>('plugin:agor-pro|pro_symbols_search', { projectPath, query });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue