顶栏智能体抽屉(Agent Drawer)

Known 在顶栏内置了一个智能体抽屉(AgentDrawer)——消息气泡、技能条、上下文条、发送框(附件 + 模型 + 语音)都在壳子里,宿主只需要登记一个实现 IAgentService 的服务,就能把一个真正能问的助手放进任何用 AdminLayout 的应用。

最后更新

装好 Known 的应用,顶栏就有一个 bot 图标。点它从右侧滑出的是一个已经能用的对话界面:能带当前页面做上下文、能选技能、能贴附件、能换模型、能语音输入、能中途停止、能翻历史会话,气泡悬停还能复制,填上中心地址还能让字从服务端一段一段吐出来。框架给的是壳子,不是答案——每轮提问交给容器里的 IAgentService(要流式就换成 IAgentStream),宿主登记一个实现,它就接上大模型;一个都不接时抽屉照常打开,每轮如实告诉你「还没接入后端」,不会把用户的话静默吞掉。

这一层壳子省下的是什么

自己搭一个「AI 助手面板」,要做的其实不是调模型,而是那一圈琐碎的界面决策:气泡左右怎么分、正在输入长什么样、上下文列在哪、附件怎么读、模型怎么选、生成中怎么打断、这些文案怎么跟着中英文走。Known 把这一圈做完了,并且只留一个契约给你填:

[WebApi]
public interface IAgentService
{
    Task<Result> SendAsync(AgentRequest request, CancellationToken cancellationToken);
}

默认形态是一次提问 = 一次调用。request 里带着本轮正文、技能标识与指令、上下文条目、附件、历史消息与所选模型 id;成功时把回答放进 Result.Data(形态是 AgentReply,见下文「宿主实现长什么样」),失败就 Result.AddError("…") 说清唯一那个未满足的条件,壳子会把它按错误气泡显示出来。Config.Agent.Stream 填上 SignalR 中心地址后,这一轮改成从服务端逐段吐字,见下文「流式输出」——不改这个开关的宿主,行为与文案一个字都没变。

打开入口与可见性

入口位置 TopBar 右侧的图标按钮(Config.Agent.Icon,默认 bot),提示文案取 Config.Agent.Title(默认「智能体」)
整体关闭 Config.Agent.Enabled = false
按角色收敛 Config.Agent.Roles = "Admin,Manager"(留空=所有已登录用户可见,语义同 [Menu].Roles)
装配点 TopBar 与 header 平级渲染 <AgentDrawer>;抽屉必须平级,因为 header 自身是 z-index: 100 的层叠上下文,嵌在里面会被侧边栏压住

想在自己的页面里嵌一个,也可以直接挂 <AgentDrawer Visible="…" VisibleChanged="…" User="@user" PageTitle="@title" />——它只认这四个参数,其余全是壳子内部的事。

壳子里的四块

消息区用框架的 Bubble 渲染:本人气泡靠右、只显示头像(取署名译名首字),助手气泡靠左、显示 bot 图标与标题;正文按 pre-wrap 原样呈现,长句不会撑破容器。鼠标移到气泡上,下方浮出一行操作区:复制按钮、本轮所用模型、时间戳。这一行是绝对定位浮在行距里的,出现与消失都不会把下面的消息顶下去。

技能选择器在发送框左下方:一枚「⚡ 技能」触发键,点开向上弹出的清单,选一项即选中。清单贴着触发键左对齐、宽度按视口夹过一道,窄屏抽屉里也不会越过抽屉边缘被裁掉。技能多起来时一字排开会占掉两三行,所以清单里超过 8 项还会多出一个搜索框,敲字即过滤技能名与说明。选中后触发键上留着该技能自己的图标与名字,一眼看得出这轮带了哪类指令;清单底部同时多出一行「清除技能」,点它即回到不带指令的默认对话(再点清单里当前这项也是同一个意思)。技能标识与指令正文随本轮请求发出,气泡下方留一枚技能标签。一个技能都没登记(或被 Roles 挡全)时,这枚触发键整颗不渲染。

// KnownApp/AppConfig.cs 里的真实登记(示例应用即这么配)
Config.AddSkill("explain", "讲讲当前页面",
    prompt: "用三句话说清当前页面解决什么问题、给谁用、下一步通常做什么。",
    description: "结合上下文里的当前页面做导览", icon: "book-open", order: 10);
Config.AddSkill("troubleshoot", "排查异常",
    prompt: "根据我贴出的报错与日志,先定位唯一未满足的条件,再给出让它满足的最小改动。",
    description: "把报错原文交给智能体定位根因", icon: "target", order: 20);

上下文条告诉智能体「用户现在站在哪里」:抽屉每次打开时,框架先登记当前页面(标题 + 站内路径),再追加宿主经 Config.Agent.ContextProvider 补的业务上下文——同 Key 后写覆盖先写,每项都能单独摘掉,摘掉的就不进本轮请求。

// 形态示意:SysOrder 是宿主自己的实体,Database 从传进来的 IServiceProvider 里取
Config.Agent.ContextProvider = async sp =>
{
    var db = sp.GetService<Database>();
    var order = await db.QueryByIdAsync<SysOrder>(_currentOrderId);
    if (order == null) return [];
    return
    [
        new AgentContextItem { Key = "order", Label = "当前单据", Value = order.Code, Icon = "file-text" },
    ];
};

发送框是 Sender:自适应高度文本域,左侧挂附件、会话历史与「新会话」,右侧发送按钮之前挂模型选择与语音输入。Enter 直接发送、Shift+Enter 换行;中文输入法选词时按的那一下 Enter 不会误发——发送判定读的是键盘事件的组合态,不是自己记的标志位。

附件:随消息内联,不落服务端存储

Config.Agent.Accept(类型白名单,留空不限)、MaxFileSize(默认 2 MB)、MaxFiles(默认 5 个)。文件读成 base64 data URL 放进 AgentRequest.Files,因此框架不需要文件存储接口,代价就是必须设上限:超限会点名是哪个文件、上限多少,而不是静默丢掉。图片附件在待发送条上显示缩略图,其余按 MIME 选图标。

模型选择与那台本机网关

发送框右下角的无边框小字就是模型条(AiModelBar 的 Mini 形态,与设计器里的「AI 生成」同一实现)。抽屉打开时它探一次本机 known-ai 并拉模型清单:内置本机 known-ai、用户自配的自定义模型(「添加自定义模型」收在下拉最后一项,密钥与 BaseURL 只交给 known-ai 保存,绝不进浏览器),以及一条只在抽屉里出现的演示模型。

选中的模型只以一个不透明 id 存进 localStorage(键 known.agent.model)并随请求发出;框架不解析它。宿主自带推理后端、不想让用户选模型时 Config.Agent.ShowModel = false 即可。

**演示模型(mock)**不联网、不调 known-ai,也不走 IAgentService:它把本轮请求原样回显一遍——正文、技能、上下文条目数、附件名、历史条数、模型标识,逐段吐出文字。它存在的理由是:后端还没接的时候,界面这套东西照样能演示、能验收,包括「正在输入 → 逐段吐字 → 点停止中断」这条最容易在评审现场出丑的链路。

流式输出:字是一段段从服务端来的

IAgentService 天生给不了流式:它走 WebApi 那条 HTTP 路,一次请求一次响应是它的天花板,IAsyncEnumerable 过不去代理;ServiceProxy 还会把 CancellationToken 滤掉,服务端从来不知道用户按了停止。所以流式换一根管子——SignalR 中心,服务端边生成边回推。

开关只有一个:Config.Agent.Stream(默认留空=不流式,现有宿主不改一行)。填上地址后,抽屉每轮走中心;服务端登记了什么决定它怎么吐字:

public interface IAgentStream
{
    // 每交回一片即推给抽屉,枚举正常结束代表本轮说完
    IAsyncEnumerable<AgentChunk> StreamAsync(AgentRequest request, CancellationToken cancellationToken);
}

AgentChunk 刻意只有两件事:Delta(本次新增的正文片段,原样拼接,换行等分隔符由实现方自己带在片段里,框架不猜)与可选的 Cards。分片只负责让字尽早出现在眼前,本轮的权威结果一律以收尾那条 Result 为准——中途丢一片只是少跳一次字,正文不会缺段落、卡片不会少一张。

接线三件事,示例宿主(KnownWeb)就是这三行:

builder.Services.AddScoped<IAgentStream, DemoAgentStream>();   // AddApplication 里,与 IAuthProvider 同一口径
app.MapHub<AgentHub>("/hubs/agent");                            // UseApplication 里
Config.Agent.Stream = "/hubs/agent";                            // 客户端入口里(KnownWasm/Program.cs)

AddScoped 不是笔误:[Service] 只把类配给 [WebApi] 契约,非 [WebApi] 的接口在 AddWebApiAndServices 里根本不会被登记,所以这条契约与 IAuthProvider 一样由宿主显式登记。中心类本身留在宿主侧——Known 是不引用 SignalR 的 Razor 类库,Hub 这个类型进不去。

客户端与中心约定四条消息:

消息 方向 载荷
Start(streamId, requestJson) 客户端 → 中心 投递本轮,必须立刻返回
AgentChunk(streamId, chunkJson) 中心 → 客户端 一片 AgentChunk
AgentDone(streamId, resultJson) 中心 → 客户端 收尾的 Result(Data 是完整的 AgentReply)
Stop(streamId) 客户端 → 中心 取消本轮推理

Start 那句「必须立刻返回」是实测出来的坑,值得单独留一句:同一条连接上的中心调用是顺序处理的。如果中心在 Send 里 await 整轮回答,用户按停止时发出的 Stop 会被排到回答结束之后才执行——界面上气泡立刻停了,服务端却还在一个字一个字地烧额度。把生成挪进后台任务(自己新建 DI 作用域,因为本次调用的作用域随 Start 返回就被释放),Stop 才真能插进队里。收尾结果因此改由 AgentDone 交出,而不是 Start 的返回值。

泵的逻辑全在框架的 AgentStreamRunner 里,宿主的中心类只负责接消息。它兜住三件容易写漏的事:没有 IAgentStream 时退回 IAgentService 整段回答(于是只接了 IAgentService 的宿主也能用流式这条管子);按 streamId 挂着取消源,Stop 与连接断开都从这里收口;把分片累计成收尾的 AgentReply。停止未命中时会留一行 [Known] 流式停止未命中 在服务端日志上——那一刻界面已经停了,只有日志说得出服务端是不是还在生成。

认证沿用应用的 Cookie:同源协商与 WebSocket 都带着会话票子,中心按 WebApiInvoker 同一口径复核 CookieAuthProvider.FromPrincipal 与 OnlineTracker.IsRevoked,被强制下线的浏览器不能继续提问。抽屉侧不引 .NET 版 SignalR 客户端:WASM 包里再塞一份传输层不值,而实时日志控制台本来就已经带着浏览器版的 signalr.js(同一份惰性加载器,两处共用)。

演示用的 KnownWeb/DemoAgentStream.cs 不调模型、不联网,按 420ms 一段把本轮请求回显出来,最后一片才交出卡片——所以按停止时卡片执行不到,不会留下一张假装查询成功的卡片。

停止、失败与「不骗人」的三条兜底

生成中发送按钮变成停止键。点停止会取消 CancellationToken,本轮结果一律按「已停止生成」收尾,不再把迟到的回答贴回气泡——用户已经明确不要它了。传输层因取消而抛的异常同样归到这条文案,不会把堆栈当回答甩给用户。

流式下这条文案的具体形态是:已经吐出来的字留在气泡里,末尾补一行「已停止生成」,卡片整张丢掉——半篇回答被抹掉、或半张卡片假装查询成功,都比「停在这里」更气人。同时这个取消是真的送到了服务端(Stop(streamId) → 取消源 → 宿主推理的 CancellationToken),不像 HTTP 那条路只是把界面停了。连接自己断了也有交代:JS 侧 onclose 会把还挂着的本轮按失败收尾,气泡不会永远「正在输入」。

没接后端时你会看到两条真实存在的提示,各自点名唯一未满足的条件:

症状 原因 该做什么
「尚未接入智能体后端:当前解析到的是框架默认实现……」 宿主程序集里没有自己的 IAgentService,命中的是框架 DefaultAgentService 用 [Service] 登记一个实现;或 Config.Agent.Enabled = false 关掉入口
「……当前请求上下文里没有 IAgentService 服务,抽屉无法发问」 容器里连框架默认实现都没解析到 确认宿主调用过 AddKnown 登记框架服务
「No agent backend is connected…」(英文界面) 同上两条,整串是词条键 改动这两段中文必须同步 en.json

会话历史:开源默认在内存,落库随 AgentKit

历史不再是组件实例的私产。框架自带 [WebApi] IAgentStore 契约与一个开箱即用的默认实现 MemoryAgentStore,抽屉发送框工具栏上因此有一枚「会话」:点开是当前用户的会话清单,按置顶、最近时间排序,分组名是今天 / 昨天 / 更早;点一行就把那一段的旧气泡(连卡片)整屏换回来,接着聊。默认这套是进程内内存、按用户隔离——刷新、重开抽屉都能在应用运行期间接着聊,但重启即清空、不跨进程、单机有效,够演示与托底。

把会话真正落库、跨重启留存属于持久化能力,随闭源 Known.AgentKit 交付:它带一个 [Service] DbAgentStore,落 SysAgentSession / SysAgentMessage 两张普通 [Table] 实体(EnsureSchemaAsync() 非破坏式自动建表,不需要迁移脚本)。宿主 Config.AddModule(typeof(AgentKitMarker).Assembly) 把 AgentKit 纳入扫描后,同名契约按「最后登记者优先」接管 IAgentStore,抽屉这一侧一个字不改,历史就从内存升级成了库——换台机器登录也还在。

契约跨 ServiceProxy / WebApi 边界只谈可移植的 AgentSession DTO(纯 POCO、不带 ORM / 审计列),不暴露任何存储形态;一共六个方法:

方法 形态 干什么
GetSessionsAsync() GET 当前用户未归档的会话,置顶优先、最近时间其次
NewSessionAsync(AgentSession) POST 先开一个会话壳子、回取服务端分配的主键(抽屉派发首轮前调用,好让本轮带着 ConversationId 关联到写账本的消费方)
AppendTurnAsync(AgentTurn) POST 一问一答连同技能、模型、附件名与卡片一起落库;SessionId 留空即新建会话
GetMessagesAsync(sessionId) GET 那个会话的消息,按时间正序还原成气泡数据
SaveSessionAsync(AgentSession) POST 只认改名与置顶,其余以存储为准
DeleteSessionsAsync(sessionIds) POST 连消息一起删,只删属于当前用户的那些

四条取舍值得说明(落库实现的行为,内存实现同口径):

  • 一轮 = 一次提交。AppendTurnAsync 把「会话 + 一问 + 一答」放进一次事务里写,中途失败就整轮不落地——历史里不会留下只有提问没有回答的半个回合。服务端新分配的主键装在 Result.Data(是 AgentSession DTO,不是一根裸 JSON 字符串)交回,抽屉接住它,后续每一轮才挂得上同一个会话。
  • 归属只认服务端判定的用户。落库实现里 CreateBy 由框架按 IAuthProvider 的当前用户写入,内存实现按同一用户名做键隔离,客户端从来不报自己是谁;别人的会话主键在你这里既查不到消息也删不掉,返回的是「会话已不存在」而不是「不是你的」——省得有人拿主键试探有没有这么一段对话。
  • 只存文件名,不存内容。附件的字节在提问时就已经交给推理,落库的是名字;卡片按 JSON 存、按 JSON 还,回放时重新画一遍。会话正文与模型 id 原样存,Archived / Summary 两列是留给归档策略的空位,存储只存不用,不替你决定什么时候归档。
  • 写失败不动气泡。答案已经送到用户眼前了,「没存进历史」只在会话弹层顶部写一条红字(本轮对话没有存进历史 / 会话改动没有保存 / 历史会话没有取回来 三种前缀之一 + 后端给的原因),不把好端端的回复标成红的;改名、置顶这类改动失败后紧接着重取一次清单,界面上不会留着一条没落库的状态。

「新会话」按钮清的是当前屏幕,旧会话仍留在清单里;删除是两步——行末「⋯」里点删除,弹层底部再确认一次,这一步之后消息才真没了。

行末那枚「⋯」的菜单走 Dropdown 的传送门(Portal)。会话弹层本身是一块会滚、又贴着抽屉底边的小面板,菜单留在里面就只能露出前两行;搬到 body 后它的层号跟着所在那一层往上取,不会被抽屉压住,点里面的「置顶 / 删除」也算点在这块弹层内部,外层清单不会一选就关掉。反过来,外层弹层展开只点亮它自己那一层,不会把每行的菜单一起掀开。

想换存储形态(换库、加密、按租户分表),用 [Service] 登记一个自己的 IAgentStore 实现即可覆盖框架默认那套(内存实现、或接上 AgentKit 后的库实现,都在同名契约「最后登记者优先」之下被顶替),抽屉这一侧一个字不用改。反过来,默认带了内存存储,这枚「会话」按钮通常一直在;只有刻意让容器里解析不到 IAgentStore(摘干净所有实现、也不留默认),它才整体消失,抽屉退回纯内存、刷新即清空的行为。

宿主实现长什么样

// 形态示意:_llm 换成你自己的推理客户端 / known-ai 调用;没有时先回一段固定话术
[Service]
public class MyAgentService(Database db) : IAgentService
{
    public async Task<Result> SendAsync(AgentRequest request, CancellationToken token)
    {
        if (string.IsNullOrWhiteSpace(request.SkillPrompt) && string.IsNullOrWhiteSpace(request.Message))
            return Result.Error("本轮既没有正文也没有技能指令,无法提问");   // 点名唯一未满足的条件

        var answer = await _llm.AskAsync(BuildPrompt(request), token);
        return Result.Success(answer, new AgentReply(answer) { Skill = request.Skill });
    }
}

Result.Success(message, data) 的两个参数都会被壳子读到:正文优先取 Data 里的 AgentReply.Text,读不到才退回 Message。所以把回答同时放进两者最省事,而 AgentReply.Skill 只能经 Data 传出去——它决定气泡下方那枚技能标记。

卡片输出走 AgentReply.Cards:正文之外,宿主技能可以把查到的东西整理成若干 AgentCard(Title + Icon + Summary + Fields + Tags),壳子用 AgentCards 画在气泡正文下方,Fields 交给描述列表排布(容器够宽时两列,窄气泡里自动收成一行)、Tags 排成一枚枚小标签。模型说「查到 3 条」不如直接把那 3 条摆出来,而这套结构在 WebAssembly 宿主下也只靠 JSON 过边界,因此卡片是数据不是片段——刻意不带按钮和链接,「点开这张单据」这类交互留给宿主自己的页面。标题、字段与标签按业务数据直接显示,不走本地化。

return Result.Success(answer, new AgentReply(answer)
{
    Skill = request.Skill,
    Cards =
    [
        new AgentCard
        {
            Title = "本月的三笔逾期",
            Icon = "alert-triangle",
            Fields = [new AgentCardField("SO-1024", "逾期 6 天 · ¥18,000")],
            Tags = ["按发货日期排序"],
        },
    ],
});

三条约束值得先知道:

  • 入参建议包成一个类。多个实体参数现在也能通:ServiceProxy 把它们按声明顺序打包成 JSON 数组,服务端由 MethodInfo.BindBodyParams 逐位还原(单参数契约的数组载荷不会被误拆)。但一个请求对象仍然更稳——参数增减时不必同时改两端;要传新字段请加到 AgentRequest 上。
  • 登记顺序决定谁生效。同名契约按「最后登记者」优先,所以宿主的 Config.AddModule<T>() 要放在 AddKnown 之后,才能覆盖框架默认实现。
  • 成功与失败不要混。失败走 Result.AddError;把错误文案塞进 AgentReply.Text,界面会当成一句正常回答。

本地化:谁翻译、谁是业务数据

壳子自己带词条,中文源串即键;en.json 里已同步整套(技能、上下文、附件、模型、停止、演示回显、两条未接入长文案、流式的七条兜底长文案)。分工是固定的:界面文案本地化,正文与业务数据不本地化——模型回复、附件文件名、时间戳、模型 id 都按原样显示。一处细节值得知道:正文经 Bubble 的 Text 渲染,而 Text 会过一次词条表,只有整段回答恰好等于某条中文源串时才会被替换掉(现实里不会撞上,但拼接式文案别指望它原样透传)。流式的 Delta 同理:它拼进正文后才过一次词条表,所以分片文案与演示宿主 DemoAgentStream 的回显都不必进 en.json(那是「模型回复」的替身),只有框架自己生成的那几条(未接入、连接断开、停止未收尾、登录失效)要整串同步。附件名这类来自文件系统的字符串直接显示,技能名与页面标题则走词条。新增文案时记得同步 D:\Publics\Known\Known\wwwroot\i18n\en.json,漏了不会报错,只会在英文界面露出中文。会话历史这套也是整串匹配:三条前缀(历史会话没有取回来 / 本轮对话没有存进历史 / 会话改动没有保存)、分组名(今天 / 昨天 / 更早)与 AgentStore 那六句业务回执都是键;红字尾巴上的异常原文(数据库报错、HTTP 状态)查不到词条,就让它保持运维要看的那个技术形态。

排查清单

现象 先看这里
顶栏没有 bot 图标 Config.Agent.Enabled 与 Config.Agent.CanSee(role);AdminLayout 之外的布局不挂 TopBar
上下文条显示 0 项 ContextProvider 抛异常会被吞进异常汇总(框架记录而不打断对话);先在错误页看有没有它
技能条整行不见 一个技能都没登记,或 Roles 把当前角色挡在外面
模型下拉只有「本机 known-ai」 known-ai 没启动/未装,GetModels 会回退成「仅本机模型」这条最省事可用清单,而不是留空
附件加了却没发出去 命中 MaxFileSize / MaxFiles,界面上有点名文件的那条提示
麦克风点了没反应 Web Speech API 由浏览器决定;不支持时图标换成 mic-off,提示说「当前浏览器不支持语音识别」
按了停止,服务端日志还在一段一段出字 中心的 Send 里 await 了整轮,Stop 被排在回答结束之后——照 KnownWeb/AgentHub.cs 改成 Start 立刻返回、生成挪后台任务
选了模型却没有流式 Config.Agent.Stream 留空(照旧一次性回答)、宿主只登记了 IAgentService(退化成整段),或选的是演示模型 mock(本地回显,本来不出网)
气泡一直「正在输入」 中心地址与 MapHub 不一致(协商 404 时抽屉按失败收尾,不会吊着);连接中途断开由 JS 侧 onclose 兜底写成「流式连接已断开」,若两者都没生效,看服务端有没有 [Known] 流式停止未命中
「会话」按钮整枚不见 默认带 MemoryAgentStore,按钮通常在;只有容器里解析不到 IAgentStore(没调用 AddKnown,或刻意摘干净所有实现又不留默认)才整体消失
清单只有「暂无历史会话」 存储与写入都在,只是当前用户名下没有会话;默认内存实现重启即清空,换进程后清单就空了——要跨重启留存接 Known.AgentKit 的库实现。换一个账号登录看不到上一个账号的历史,这是归属判定的预期结果
会话行的「⋯」一打开清单就全展开 外层展开态用 .dropdown.open .dropdown-menu 命中了后代——只认直接子元素(.dropdown.open > .dropdown-menu),嵌进来的子弹层才不会跟着点亮
会话行的「⋯」点开只露出半截 行菜单没走传送门:.cv-list 是会滚的裁剪容器,绝对定位的浮层在里面一律被裁;Dropdown 上要留着 Portal="true",它的层号才会跟着抽屉往上取(2900 → 2910)而不是沉在面板背后
弹层顶部红字「本轮对话没有存进历史 / 会话改动没有保存」 写库没成。尾巴上那句是原因:正文为空、会话已不存在(刚被删或不属于当前用户)、或传输异常(HTTP 500 一类);回答本身已经在气泡里,不用重问
启动日志一串 Column nullability mismatch 每张表都有,SQLite 报不出列的可空性;接了 Known.AgentKit 才有 SysAgentSession / SysAgentMessage 这两张表,建表成功与否看库里有没有行,不看这行告警
英文界面夹中文 该句是整串匹配的长文案键,en.json 里补上即可

相关页面

微信公众号二维码 扫码关注公众号
QQ 交流群二维码 扫码加入 QQ 群