feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen #1

Merged
Blizzard merged 181 commits from feat/wails3 into main 2026-07-17 01:12:32 +00:00
3 changed files with 81 additions and 7 deletions
Showing only changes of commit 511ae199a4 - Show all commits
+24 -2
View File
@@ -14,7 +14,7 @@ import { Placeholder } from "./views/Placeholder";
import { CommandPalette, type Command } from "./components/CommandPalette"; import { CommandPalette, type Command } from "./components/CommandPalette";
import { UpdateBanner } from "./components/UpdateBanner"; import { UpdateBanner } from "./components/UpdateBanner";
import { Login } from "./views/Login"; import { Login } from "./views/Login";
import { submitTask, streamTokens, streamExec, taskStatus, listRuns, authMe, logout, type Identity, type AuthUser } from "./lib/api"; import { submitTask, streamTokens, streamExec, taskStatus, listRuns, authMe, logout, tenantCurrent, type Identity, type AuthUser, type TenantCtx } from "./lib/api";
import type { TaskDsl } from "./lib/dsl"; import type { TaskDsl } from "./lib/dsl";
import { emptyRun, type RunState } from "./lib/run"; import { emptyRun, type RunState } from "./lib/run";
import { ToastProvider } from "./ui"; import { ToastProvider } from "./ui";
@@ -41,6 +41,7 @@ const PLACEHOLDERS: Partial<Record<ViewKey, { title: string; desc: string }>> =
export default function App() { export default function App() {
const [view, setView] = useState<ViewKey>("home"); const [view, setView] = useState<ViewKey>("home");
const [user, setUser] = useState<AuthUser | null>(null); const [user, setUser] = useState<AuthUser | null>(null);
const [tenant, setTenant] = useState<TenantCtx | null>(null);
const [authLoading, setAuthLoading] = useState(true); const [authLoading, setAuthLoading] = useState(true);
const identity = useMemo<Identity>(() => ({ userId: user?.id ?? "", sessionId: getSessionId() }), [user]); const identity = useMemo<Identity>(() => ({ userId: user?.id ?? "", sessionId: getSessionId() }), [user]);
const [run, setRun] = useState<RunState>(emptyRun); const [run, setRun] = useState<RunState>(emptyRun);
@@ -82,8 +83,29 @@ export default function App() {
const onLogout = useCallback(() => { const onLogout = useCallback(() => {
logout(); logout();
setUser(null); setUser(null);
setTenant(null);
}, []); }, []);
// 租户上下文 + 积分余额:登录后拉取,并每 20s 轮询保持大致实时(顶栏余额芯片用)。
const refreshTenant = useCallback(() => {
tenantCurrent()
.then(setTenant)
.catch(() => {});
}, []);
useEffect(() => {
if (!user) {
setTenant(null);
return;
}
refreshTenant();
const t = window.setInterval(refreshTenant, 20000);
return () => window.clearInterval(t);
}, [user, refreshTenant]);
// 运行结束余额会被扣,立即刷新一次(比等轮询更跟手)。
useEffect(() => {
if (run.phase === "done") refreshTenant();
}, [run.phase, refreshTenant]);
const commands = useMemo<Command[]>(() => { const commands = useMemo<Command[]>(() => {
const go = (key: ViewKey) => () => setView(key); const go = (key: ViewKey) => () => setView(key);
return [ return [
@@ -216,7 +238,7 @@ export default function App() {
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }} style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
/> />
<UpdateBanner /> <UpdateBanner />
<TopBar user={user} onLogout={onLogout} onCommand={() => setCmdOpen(true)} /> <TopBar user={user} tenant={tenant} onLogout={onLogout} onCommand={() => setCmdOpen(true)} />
<ApprovalBar run={run} /> <ApprovalBar run={run} />
<div className="relative flex min-h-0 flex-1"> <div className="relative flex min-h-0 flex-1">
<LeftNav active={view} onSelect={setView} /> <LeftNav active={view} onSelect={setView} />
+31 -1
View File
@@ -104,11 +104,41 @@ export async function submitTask(dsl: TaskDsl, id: Identity): Promise<string> {
body: JSON.stringify(dsl), body: JSON.stringify(dsl),
}), }),
); );
if (!res.ok) throw new Error(`submit failed: ${res.status} ${await res.text()}`); if (!res.ok) {
// 402=积分/预算不足等业务拒绝:透出后端的友好文案(如「租户积分余额不足,请充值后再试」)。
let msg = `submit failed: ${res.status}`;
try {
const d = (await res.json()) as { error?: string };
if (d.error) msg = d.error;
} catch {
/* 非 JSON 响应,保留状态码文案 */
}
throw new Error(msg);
}
const data = (await res.json()) as { task_id: string }; const data = (await res.json()) as { task_id: string };
return data.task_id; return data.task_id;
} }
// ---- 租户上下文 + 积分余额(面向当前用户自己的租户;只读,不碰 admin 口径)----
export interface TenantCtx {
tenant: { id: string; name: string; plan: string; status: string } | null;
role: string;
credit_balance_micro: number;
credit_enforce: boolean; // 平台是否开了余额硬拦截(开了且余额≤0 会拒绝提交)
}
export async function tenantCurrent(): Promise<TenantCtx | null> {
const res = guard401(await fetch(`${GATEWAY}/api/v1/tenants/current`, { headers: bearer() }));
if (!res.ok) return null;
const d = (await res.json()) as Partial<TenantCtx>;
return {
tenant: d.tenant ?? null,
role: d.role ?? "",
credit_balance_micro: d.credit_balance_micro ?? 0,
credit_enforce: !!d.credit_enforce,
};
}
// taskStatus: GET /api/v1/tasks/:id —— 任务生命周期状态(submitted/running/waiting/done/failed/timeout/rejected)。 // taskStatus: GET /api/v1/tasks/:id —— 任务生命周期状态(submitted/running/waiting/done/failed/timeout/rejected)。
// 轮询它来可靠捕获 waiting(审批中断)—— 不依赖易抢跑的实时 exec 事件。 // 轮询它来可靠捕获 waiting(审批中断)—— 不依赖易抢跑的实时 exec 事件。
export async function taskStatus(taskId: string): Promise<{ status: string; detail: string }> { export async function taskStatus(taskId: string): Promise<{ status: string; detail: string }> {
+26 -4
View File
@@ -1,6 +1,6 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon } from "lucide-react"; import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon, Coins } from "lucide-react";
import type { AuthUser } from "../lib/api"; import type { AuthUser, TenantCtx } from "../lib/api";
import { useHealth } from "../lib/health"; import { useHealth } from "../lib/health";
import { isMacDesktop } from "../lib/desktop"; import { isMacDesktop } from "../lib/desktop";
import { useTheme } from "../lib/theme"; import { useTheme } from "../lib/theme";
@@ -20,8 +20,29 @@ function Light({ on, label }: { on: boolean; label: string }) {
); );
} }
// 顶栏:品牌 · 垂直切换 · 健康灯 · 登录用户 + 登出(深色 + 毛玻璃) // 积分余额芯片:让用户在主产品里随时看到自己租户剩多少积分
export function TopBar({ user, onLogout, onCommand }: { user: AuthUser; onLogout: () => void; onCommand?: () => void }) { // 硬拦截开启且余额≤0 → 红色「余额不足」;偏低 → 橙;否则常态。
function CreditChip({ tenant }: { tenant: TenantCtx }) {
const credits = tenant.credit_balance_micro / 1_000_000;
const empty = tenant.credit_enforce && credits <= 0;
const low = !empty && credits > 0 && credits < 10;
const tone = empty ? "border-danger/50 text-danger" : low ? "border-warn/50 text-warn" : "border-line text-slate-300";
return (
<span
className={cn("flex items-center gap-1.5 rounded-md border bg-ink-800 px-2.5 py-1 text-xs", tone)}
title={
`租户 ${tenant.tenant?.name ?? ""} · 积分余额 ${credits.toLocaleString("zh-CN", { maximumFractionDigits: 2 })}` +
(tenant.credit_enforce ? "(余额≤0 将无法提交任务,请联系管理员充值)" : "(当前不拦截,仅计量)")
}
>
<Coins className="h-3.5 w-3.5 opacity-70" />
{empty ? "余额不足" : credits.toLocaleString("zh-CN", { maximumFractionDigits: credits < 100 ? 1 : 0 })}
</span>
);
}
// 顶栏:品牌 · 垂直切换 · 健康灯 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)。
export function TopBar({ user, tenant, onLogout, onCommand }: { user: AuthUser; tenant?: TenantCtx | null; onLogout: () => void; onCommand?: () => void }) {
const h = useHealth(); const h = useHealth();
const { theme, toggle } = useTheme(); const { theme, toggle } = useTheme();
return ( return (
@@ -64,6 +85,7 @@ export function TopBar({ user, onLogout, onCommand }: { user: AuthUser; onLogout
<Light on={h.neo4j} label="Neo4j" /> <Light on={h.neo4j} label="Neo4j" />
</div> </div>
<div className="ml-auto flex items-center gap-2" style={NODRAG}> <div className="ml-auto flex items-center gap-2" style={NODRAG}>
{tenant?.tenant && <CreditChip tenant={tenant} />}
<button <button
onClick={toggle} onClick={toggle}
className="flex items-center rounded-md border border-line bg-ink-800 px-2 py-1 text-slate-400 transition hover:border-ink-600 hover:text-slate-200" className="flex items-center rounded-md border border-line bg-ink-800 px-2 py-1 text-slate-400 transition hover:border-ink-600 hover:text-slate-200"