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
5 changed files with 112 additions and 11 deletions
Showing only changes of commit 9dab25a5f0 - Show all commits
+11 -1
View File
@@ -139,6 +139,16 @@ export default function App() {
},
[refreshTenant],
);
// 启用全员空间(整租户共享;幂等纳入全部成员)——须租户 admin。
const onEnableTenantSpace = useCallback(async () => {
try {
const { id } = await createSpace("全员空间", "tenant");
await switchSpace(id);
refreshTenant();
} catch (e) {
window.alert((e as Error).message);
}
}, [refreshTenant]);
useEffect(() => {
if (!user) {
setTenant(null);
@@ -288,7 +298,7 @@ export default function App() {
style={{ background: "radial-gradient(60% 100% at 50% 0%, rgba(124,92,246,0.10), transparent 70%)" }}
/>
<UpdateBanner />
<TopBar user={user} tenant={tenant} tenants={tenants} onSwitchTenant={onSwitchTenant} space={space} spaces={spaces} onSwitchSpace={onSwitchSpace} onCreateSpace={onCreateSpace} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
<TopBar user={user} tenant={tenant} tenants={tenants} onSwitchTenant={onSwitchTenant} space={space} spaces={spaces} onSwitchSpace={onSwitchSpace} onCreateSpace={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} tenantRole={tenant?.role ?? ""} onLogout={onLogout} onCommand={() => setCmdOpen(true)} onOpenUsage={() => setView("usage")} />
<ApprovalBar run={run} />
<div className="relative flex min-h-0 flex-1">
<LeftNav active={view} onSelect={setView} />
+17 -4
View File
@@ -1,5 +1,5 @@
import type { CSSProperties } from "react";
import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon, Coins, Building2, Users as UsersIcon, Plus } from "lucide-react";
import { User, ChevronDown, Search as SearchIcon, LogOut, Sun, Moon, Coins, Building2, Users as UsersIcon, Plus, Globe } from "lucide-react";
import type { AuthUser, TenantCtx, MyTenant, SpaceCtx, MySpace } from "../lib/api";
import { useHealth } from "../lib/health";
import { isMacDesktop } from "../lib/desktop";
@@ -67,9 +67,11 @@ function TenantSwitcher({ tenants, activeId, onSwitch }: { tenants: MyTenant[];
// 工作区切换器:切换活跃 Space(共享工作区 — 个人/项目/全员)。含"新建项目空间"。
// 个人空间恒有,故 ≥1 即显示(让用户随时能建/进项目协作空间)。
function SpaceSwitcher({ spaces, activeId, onSwitch, onCreate }: { spaces: MySpace[]; activeId?: string; onSwitch?: (id: string) => void; onCreate?: (name: string) => void }) {
function SpaceSwitcher({ spaces, activeId, onSwitch, onCreate, onEnableTenantSpace, tenantRole }: { spaces: MySpace[]; activeId?: string; onSwitch?: (id: string) => void; onCreate?: (name: string) => void; onEnableTenantSpace?: () => void; tenantRole?: string }) {
if (spaces.length === 0) return null;
const label = (s: MySpace) => (s.kind === "personal" ? "个人空间" : s.name) + (s.kind !== "personal" ? ` · ${s.members}` : "");
const canManage = tenantRole === "owner" || tenantRole === "admin";
const hasTenantSpace = spaces.some((s) => s.kind === "tenant");
return (
<div className="flex items-center gap-1" style={NODRAG}>
<div className="relative">
@@ -98,12 +100,23 @@ function SpaceSwitcher({ spaces, activeId, onSwitch, onCreate }: { spaces: MySpa
>
<Plus className="h-3.5 w-3.5" />
</button>
{canManage && !hasTenantSpace && (
<button
onClick={() => {
if (window.confirm("启用「全员空间」?将把本租户全部成员纳入一个共享工作区(整租户共享编排/知识库)。")) onEnableTenantSpace?.();
}}
title="启用全员空间(整租户共享,自动纳入所有成员)"
className="flex items-center rounded-md border border-line bg-ink-800 px-1.5 py-1 text-slate-400 transition hover:border-ink-600 hover:text-slate-200"
>
<Globe className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
// 顶栏:品牌 · 垂直切换 · 健康灯 · 租户/工作区切换 · 积分余额 · 登录用户 + 登出(深色 + 毛玻璃)。
export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spaces = [], onSwitchSpace, onCreateSpace, onLogout, onCommand, onOpenUsage }: { user: AuthUser; tenant?: TenantCtx | null; tenants?: MyTenant[]; onSwitchTenant?: (id: string) => void; space?: SpaceCtx | null; spaces?: MySpace[]; onSwitchSpace?: (id: string) => void; onCreateSpace?: (name: string) => void; onLogout: () => void; onCommand?: () => void; onOpenUsage?: () => void }) {
export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spaces = [], onSwitchSpace, onCreateSpace, onEnableTenantSpace, tenantRole, onLogout, onCommand, onOpenUsage }: { user: AuthUser; tenant?: TenantCtx | null; tenants?: MyTenant[]; onSwitchTenant?: (id: string) => void; space?: SpaceCtx | null; spaces?: MySpace[]; onSwitchSpace?: (id: string) => void; onCreateSpace?: (name: string) => void; onEnableTenantSpace?: () => void; tenantRole?: string; onLogout: () => void; onCommand?: () => void; onOpenUsage?: () => void }) {
const h = useHealth();
const { theme, toggle } = useTheme();
return (
@@ -147,7 +160,7 @@ export function TopBar({ user, tenant, tenants = [], onSwitchTenant, space, spac
</div>
<div className="ml-auto flex items-center gap-2" style={NODRAG}>
<TenantSwitcher tenants={tenants} activeId={tenant?.tenant?.id} onSwitch={onSwitchTenant} />
<SpaceSwitcher spaces={spaces} activeId={space?.space?.id} onSwitch={onSwitchSpace} onCreate={onCreateSpace} />
<SpaceSwitcher spaces={spaces} activeId={space?.space?.id} onSwitch={onSwitchSpace} onCreate={onCreateSpace} onEnableTenantSpace={onEnableTenantSpace} tenantRole={tenantRole} />
{tenant?.tenant && <CreditChip tenant={tenant} onClick={onOpenUsage} />}
<button
onClick={toggle}
+18 -6
View File
@@ -57,20 +57,32 @@ func (h *Handler) SpaceCurrent(c *gin.Context) {
// SpaceCreate: POST /api/v1/spaces {name, kind} —— 在活跃租户建一个工作区(建者成为 owner)。
// 已由 RequireTenantRole(member) 门控:租户只读 viewer 进不来。
// kind=tenant(全员空间)走 EnableTenantSpace(幂等纳入全部成员),且须租户 admin。
func (h *Handler) SpaceCreate(c *gin.Context) {
var b struct {
Name string `json:"name"`
Kind string `json:"kind"`
}
if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.Name) == "" {
_ = c.ShouldBindJSON(&b)
if b.Kind == store.SpaceKindTenant {
// 启用全员空间:整租户级动作,须租户 admin/owner。
if store.RoleRank(h.db.MemberRole(c.Request.Context(), tenantID(c), userID(c))) < store.RoleRank(store.RoleAdmin) {
c.JSON(http.StatusForbidden, gin.H{"error": "启用全员空间需要租户管理员权限"})
return
}
s, err := h.db.EnableTenantSpace(c.Request.Context(), tenantID(c))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"id": s.ID, "name": s.Name, "kind": s.Kind})
return
}
if strings.TrimSpace(b.Name) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name 必填"})
return
}
kind := b.Kind
if kind != store.SpaceKindProject && kind != store.SpaceKindTenant {
kind = store.SpaceKindProject // 只允许经此建 project/tenantpersonal 由系统建
}
s, err := h.db.CreateSpace(c.Request.Context(), tenantID(c), b.Name, kind, userID(c))
s, err := h.db.CreateSpace(c.Request.Context(), tenantID(c), b.Name, store.SpaceKindProject, userID(c))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
+64
View File
@@ -207,6 +207,70 @@ func (p *Postgres) CreateSpace(ctx context.Context, tenantID, name, kind, creato
return s, nil
}
// ---- 全员空间(tenant-kind:整租户共享,自动纳入全部成员)----
// tenantSpaceID 返回某租户的全员空间 id(无则空 = 未启用)。
func (p *Postgres) tenantSpaceID(ctx context.Context, tenantID string) string {
var s Space
if err := p.db.WithContext(ctx).Select("id").
Where("tenant_id = ? AND kind = ? AND deleted_at IS NULL AND archived = false", tenantID, SpaceKindTenant).
Order("created_at asc").First(&s).Error; err != nil {
return ""
}
return s.ID
}
// spaceRoleFromTenant 把租户角色映射为空间角色:billing_admin(财务只读)→viewer,其余照搬。
func spaceRoleFromTenant(role string) string {
if role == RoleBillingAdmin {
return RoleViewer
}
return role
}
// EnableTenantSpace 幂等启用某租户的全员空间:已启用则返回;否则建 kind=tenant 空间 +
// 把当前全部 active 租户成员按其租户角色纳入(owner→owner…billing_admin→viewer)。
// 新加入租户的成员由 AddMemberByEmail 自动补纳(见其实现)。
func (p *Postgres) EnableTenantSpace(ctx context.Context, tenantID string) (*Space, error) {
if p.db == nil {
return nil, errStoreDisabled
}
if id := p.tenantSpaceID(ctx, tenantID); id != "" {
return p.GetSpace(ctx, id)
}
members, err := p.ListMembers(ctx, tenantID)
if err != nil {
return nil, err
}
creator := ""
for _, m := range members {
if m.Role == RoleOwner {
creator = m.UserID
break
}
}
s := &Space{TenantID: tenantID, Name: "全员空间", Kind: SpaceKindTenant, Creator: creator}
if err := p.db.WithContext(ctx).Create(s).Error; err != nil {
return nil, err
}
for _, m := range members {
if m.Status != "active" {
continue
}
if err := p.addSpaceMember(ctx, s.ID, m.UserID, spaceRoleFromTenant(m.Role)); err != nil {
return nil, err
}
}
return s, nil
}
// autoJoinTenantSpace 若某租户已启用全员空间,把用户按其租户角色纳入(AddMemberByEmail 调用)。
func (p *Postgres) autoJoinTenantSpace(ctx context.Context, tenantID, userID, tenantRole string) {
if sid := p.tenantSpaceID(ctx, tenantID); sid != "" {
_ = p.addSpaceMember(ctx, sid, userID, spaceRoleFromTenant(tenantRole))
}
}
// ArchiveSpace 归档空间(软;个人空间不可归档)。
func (p *Postgres) ArchiveSpace(ctx context.Context, spaceID string) error {
if p.db == nil {
@@ -241,6 +241,8 @@ func (p *Postgres) AddMemberByEmail(ctx context.Context, tenantID, email, role s
}).Create(&TenantMember{TenantID: tenantID, UserID: u.ID, Role: role, Status: "active"}).Error; err != nil {
return nil, err
}
// 若该租户已启用全员空间,新成员自动纳入(全员空间的语义:全租户成员皆在内)。
p.autoJoinTenantSpace(ctx, tenantID, u.ID, role)
return &MemberInfo{UserID: u.ID, Email: u.Email, Name: u.Name, Role: role, Status: "active"}, nil
}