Polish assistant UI and add Service Mesh video case

This commit is contained in:
Haitao Pan 2026-05-25 13:43:00 +08:00
parent 25e8b17dd8
commit 31990b30d9
12 changed files with 408 additions and 215 deletions

View File

@ -6,6 +6,7 @@
- [核心功能集成测试手动 Case](./core-integration-manual-cases.md)
- [AI 安全演进内容生成场景测试用例](./ai-security-evolution-content-scenario/README.md)
- [云原生 Service Mesh 网络科普视频调研场景测试用例](./service-mesh-evolution-video-scenario/README.md)
## 配套文档

View File

@ -1,22 +1,10 @@
# 从单机权限到 AI 模型与知识保护:安全边界正在重新定义
# 从单机权限到 AI 模型与知识保护:一张表看懂边界演进
## 开场
过去几十年,企业安全一直围绕“边界”展开。
早期的边界是单机权限:谁能登录这台机器,谁能读取这个目录,谁能执行这个程序。
后来边界变成网络:哪些端口开放,哪些网段能访问,哪些请求可以穿过防火墙。
再后来,业务搬到 Web 和云上安全控制面继续向应用、身份、API、云账号和运行时迁移。
到了 AI Agent 时代,边界又一次发生变化:系统里开始出现一个会代表人执行任务的新主体。
这意味着,安全不再只是保护“入口”,而是要治理“行动”。
过去几十年,企业安全一直围绕“边界”展开。现在,边界从机器、网络和应用,迁移到了身份、知识和行动主体。
## 一张表看安全边界的演进
| 演进路径 | 当下判断 |
| 演进路径 | 当下 |
| --- | --- |
| 单机权限 | 权限不再只发生在单机,终端只是身份、数据和任务链路的入口。 |
| 网络边界 | 边界从机房防火墙变成 API、身份、设备、供应链和运行时的组合面。 |
@ -26,98 +14,32 @@
| AI Agent 身份 | Agent 开始代表人执行任务,必须有独立身份、授权边界、审计链和可撤销能力。 |
| AI 模型与知识保护 | 模型、提示词、RAG 知识库、工具调用记录和业务语料共同成为新资产边界。 |
## 第一阶段:单机权限
## 现在最关键的变化
单机时代,安全问题相对直接
过去我们问的是:谁能登录这台机器
系统管理员关心的是本机账号、文件权限、进程权限和本地审计。权限模型围绕一台机器展开,攻击面也主要集中在本机登录、提权和文件访问
后来我们问的是:谁能从这个网络边界进入
这个阶段的核心问题是:谁能进入这台机器?
再后来我们问的是这个用户、设备、会话、API 调用是否可信。
## 第二阶段:网络边界
现在必须继续往前问:
当系统开始联网,边界从单机扩展到网络。
这个 AI Agent 是谁授权的?
它能读哪些知识?
能调用哪些工具?
能代表人做到哪一步?
出了问题能不能撤销、审计、追责?
防火墙、网段隔离、端口策略、VPN 和入侵检测成为安全体系的关键组件。企业开始把“内网”看作可信区域,把“外网”看作不可信区域。
## 当下的安全重点
这个阶段的核心问题是:哪些连接可以进入系统?
AI 安全已经不是“给模型加一层登录”就够了,而是要同时治理三条边界:
## 第三阶段Web 安全
业务上 Web 之后,安全风险进入应用层。
SQL 注入、XSS、CSRF、越权访问、上传漏洞、会话劫持等问题使安全团队必须理解业务逻辑本身。攻击者不一定需要突破网络边界只要利用应用逻辑缺陷就可能拿到数据或权限。
这个阶段的核心问题是:业务入口是否可信?
## 第四阶段:云身份
云计算普及后,身份成为新的控制平面。
云账号、角色、策略、密钥、服务账号、跨账号授权,共同决定资源能否被访问。很多严重事故并不是因为服务器被攻破,而是因为身份权限过大、密钥泄露、临时授权失控。
这个阶段的核心问题是:谁以什么身份访问什么资源?
## 第五阶段Zero Trust
Zero Trust 的价值,是把“默认信任”从架构里拿掉。
不再因为请求来自内网、某台设备或某个固定网段就直接放行,而是持续验证身份、设备、位置、行为、风险和上下文,并以最小权限完成授权。
这个阶段的核心问题是:这一次访问,在当前上下文里是否仍然可信?
## 第六阶段AI Agent 身份
AI Agent 出现后,安全主体发生变化。
传统应用多数是被动响应请求,而 Agent 会主动规划任务、调用工具、读写文件、访问外部系统,甚至连续执行多步操作。
因此Agent 不能只是“某个用户会话里的模型”。它需要具备独立身份:
- 谁创建了这个 Agent
- 它代表哪个用户或组织执行任务?
- 它可以调用哪些工具?
- 它能访问哪些文件和知识库?
- 它的动作如何审计?
- 它的权限如何撤销?
这个阶段的核心问题是Agent 能代表人做到哪一步?
## 第七阶段AI 模型与知识保护
AI 应用真正敏感的资产,不只在数据库里。
提示词、系统指令、RAG 知识库、训练或微调数据、工具调用记录、业务上下文、用户上传文件,都可能承载企业知识和权限边界。
如果这些内容被错误注入上下文、被越权检索、被日志泄露,或者被 Agent 带入不该调用的工具链路,风险就会从“数据泄露”升级成“自动化误执行”。
这个阶段的核心问题是:模型和 Agent 能携带哪些知识去执行动作?
## 当下的架构重点
AI Agent 安全至少需要四个控制面:
1. 身份控制面区分人、应用、Agent、工具和服务账号。
2. 知识控制面:按组织、项目、用户、任务隔离 RAG 和文件上下文。
3. 工具控制面:对每个工具调用做授权、参数约束、审批和限流。
4. 审计控制面:记录从用户意图到 Agent 计划、工具调用、结果产物的完整链路。
这四个控制面缺一不可。
只有身份没有知识隔离Agent 仍可能读到不该读的内容。
只有网关没有工具授权Agent 仍可能用正确入口做错误动作。
只有日志,没有可撤销能力,事故发生后仍然难以止损。
1. 身份边界人、应用、Agent、工具都要有可区分身份。
2. 知识边界RAG、提示词、文件、业务语料按权限进入上下文。
3. 行动边界Agent 调工具、改数据、发请求、创建任务都要可授权、可追踪、可撤销。
## 结语
安全边界的演进,本质上是计算形态的演进
安全已经从“谁能访问系统”升级为“谁能携带什么知识,以什么身份,替谁执行什么动作”。
单机时代保护机器网络时代保护边界Web 时代保护业务入口云时代保护身份Zero Trust 时代保护每一次访问。
AI Agent 时代,需要保护的是:一个携带知识、拥有工具、能代表人执行任务的智能体。
未来的企业安全,不只是“谁能访问系统”,而是:
谁能携带什么知识,以什么身份,替谁执行什么动作。
这就是 AI 时代的边界迁移。

View File

@ -1,8 +1,6 @@
# X 风格文案:安全边界正在迁移
# X 风格文案:安全边界正在往“行动主体”迁移
## 长帖
安全的主线,正在从“保护机器”迁移到“约束会行动的智能体”。
安全边界的主线,已经从“谁能登录机器”走到了“谁能代表人做事”。
| 演进路径 | 当下 |
| --- | --- |
@ -14,46 +12,44 @@
| AI Agent 身份 | Agent 代表人执行任务,必须有独立身份、授权边界和审计链。 |
| AI 模型与知识保护 | 模型、提示词、RAG 知识库和工具调用记录,都变成资产边界。 |
过去,我们问:这台机器谁能登录?
过去,我们问的是:这台机器谁能登录。
后来,我们问:这个服务从哪个网段进来?
后来,我们问的是:这个服务从哪个网段进来。
再后来,我们问:这个用户、这个设备、这个会话、这个 API 调用是否可信
再后来,我们问的是:这个用户、这个设备、这个会话、这个 API 调用是否可信
现在必须继续往前问:
现在继续往前问:
这个 AI Agent 是谁授权的?它能读哪些知识?能调用哪些工具?能代表人做到哪一步?失败后谁负责?如何撤销?如何审计?
这个 AI Agent 是谁授权的?
它能读哪些知识?
能调用哪些工具?
能代表人做到哪一步?
出了问题能不能撤销、审计、追责?
AI 时代的安全不是给模型外面套一层网关就结束了。
AI 时代的安全不是给模型外面套一层网关就结束了。
真正的边界在三处:
真正需要控制,是三条边界:
1. 身份边界人、应用、Agent、工具都要有可区分身份。
2. 知识边界RAG、提示词、文件、业务语料按权限进入上下文。
2. 知识边界RAG、提示词、文件、业务语料按权限进入上下文。
3. 行动边界Agent 调工具、改数据、发请求、创建任务都要可授权、可追踪、可撤销。
一句话:
安全从“谁能访问系统”升级为“谁能携带什么知识,以什么身份,替谁执行什么动作”。
安全已经从“谁能访问系统”升级为“谁能携带什么知识,以什么身份,替谁执行什么动作”。
## 短帖
安全边界的演进:
单机权限 -> 网络边界 -> Web 安全 -> 云身份 -> Zero Trust -> AI Agent 身份 -> AI 模型与知识保护
当下最重要的变化是:
真正变化的是AI Agent 不只是聊天入口,而是新的行动主体。
AI Agent 不只是一个聊天入口,而是会代表人调用工具、读取知识、执行任务的新主体
所以安全不再只问“能不能访问”,而是要问“能不能代表用户把这件事做完”
所以安全问题也变了:
身份、知识、工具、审计,会变成 AI 应用的四个基本控制面。
不是只问“用户能不能访问”而是要问“Agent 能不能代表用户把这件事做完”。
## 适合发布
身份、知识、工具、审计,会成为 AI 应用的四个基本控制面。
## 发布建议
- 适合配一张横向演进图。
- 首条评论可补充Agent 身份不是模型账号,而是任务执行主体。
- 适合配一张横向演进表或时间轴图。
- 首条评论可补一句Agent 身份不是模型账号,而是任务执行主体。
- 互动问题:你们现在的 AI Agent 权限,是按人、按应用,还是按任务隔离?

View File

@ -2,8 +2,8 @@
## 标题备选
- AI Agent 时代,安全边界不再只是账号和防火墙
- 从单机权限到模型知识保护,一张表看懂安全演进
- AI Agent 时代,安全边界不再只是账号和防火墙
- 为什么 AI 应用上线后,权限体系要重新设计?
## 正文
@ -12,9 +12,9 @@
有账号、有网关、有日志,就觉得差不多了。
但 AI Agent 的问题是:它不只是“回答问题”,它会读取知识、调用工具、创建任务、修改文件、访问外部系统。安全边界因此发生了迁移。
但 AI Agent 不只是“回答问题”,它会读取知识、调用工具、创建任务、修改文件、访问外部系统。安全边界因此发生了迁移。
| 演进阶段 | 当下应该关注什么 |
| 演进路径 | 当下 |
| --- | --- |
| 单机权限 | 终端不再是完整边界,只是身份和任务链路的入口。 |
| 网络边界 | 防火墙仍重要,但 API、设备、身份、供应链才是新组合面。 |
@ -29,15 +29,10 @@
AI 安全不是“给模型加一层登录”,而是要回答:
这个 Agent 是谁?
它代表谁?
它能读哪些知识?
它能调用哪些工具?
它执行过什么动作?
出问题后能不能撤销和追责?
## 适合团队自查的 5 个问题

View File

@ -0,0 +1,45 @@
# 云原生 Service Mesh 网络科普视频调研场景测试用例
这个目录保存一个用于 XWorkmate App 的内容生产场景测试用例。测试目标是验证 assistant 线程能围绕“云原生 Service Mesh 网络”主题,先生成连续系列图片,再基于系列图片生成科普视频,并保持产物归属当前线程 workspace。
## 场景主题
| 演进路径 | 当下判断 |
| --- | --- |
| 单机网络控制 | 早期网络主要围绕主机端口、进程监听和机器间连通性。 |
| 传统网络边界 | 边界更多依赖防火墙、ACL、网段隔离和静态策略。 |
| 容器网络 | 容器化后,服务发现、东西向流量和弹性伸缩让网络控制更动态。 |
| Kubernetes 网络 | K8s 把网络抽象为 Service、Ingress、Gateway、Policy、DNS 和 CNI。 |
| Service Mesh | Service Mesh 把流量治理、可观测性、熔断、重试和身份控制下沉到基础设施层。 |
| 云原生零信任 | mTLS、工作负载身份、细粒度授权和策略执行让“网络边界”变成“身份边界”。 |
| 当下实践 | Service Mesh 不只是流量转发层,而是云原生安全、治理和发布控制的组合面。 |
## 产物
- [系列图片](./assets/)
- [科普视频](./video/)
- [自媒体文案规划](./social-plan.md)
## App 手动测试提示词
```text
请围绕“云原生 Service Mesh 网络”主题做一次科普视频调研,输出一个适合视频制作的内容方案。
要求:
1. 先使用 it-infra-continuous-png 生成一组系列图片,图片要按时间演进讲清楚:单机网络控制 -> 传统网络边界 -> 容器网络 -> Kubernetes 网络 -> Service Mesh -> 云原生零信任 -> 当下实践。
2. 再使用 it-infra-evolution-video基于输入的系列图片制作一支科普视频。
3. 再使用 content_series_infographic 输出一份自媒体文案规划,包含系列总主题、核心叙事线、周排期、图文标题和长文标题建议。
4. 输出中要返回每一步生成的文件路径,并说明系列图片、视频和文案规划各自适合的展示场景。
5. 内容表达面向科普,不要只做概念堆叠,要讲清楚 Service Mesh 解决了什么问题,当前又有哪些局限。
要求:所有产物都写入当前线程 workspace返回文件路径且后续修改时仍基于同一条主题链路继续迭代。
```
## 期望结果
- 当前线程 artifact 区出现一组连续系列图片和 1 个视频文件。
- 当前线程 artifact 区出现一份适合社媒连载的文案规划。
- 系列图片按演进链路排列,能单独用于长图、轮播或分镜预览。
- 视频基于系列图片生成,内容能完整讲清楚云原生 Service Mesh 的演进、能力边界和当前实践。
- 文案规划能直接拆成图文连载与长文连载,并保持同一主线叙事。
- 同线程继续追问修改时,仍基于这组图片和视频上下文。

View File

@ -0,0 +1,99 @@
# 云原生 Service Mesh 网络科普视频自媒体文案规划
## 系列总主题
《云原生 Service Mesh 网络》第一季
## 核心钩子
网络没有消失,只是从“边界设备”迁移到了“服务之间的流动、身份和策略执行”。
## 核心叙事线
单机网络控制 → 传统网络边界 → 容器网络 → Kubernetes 网络 → Service Mesh → 云原生零信任 → 当下实践
## 明线
云原生环境里网络控制为什么越来越难Service Mesh 为什么成为治理和安全的重要基础设施。
## 暗线
流量治理、可观测性、身份认证、细粒度授权、发布控制,如何从“附加能力”变成“底层能力”。
## 周排期
| 日期 | 主题 | 图文 1 | 图文 2 | 图文 3 | 长文 |
| --- | --- | --- | --- | --- | --- |
| 周一 | 单机到边界 | 一图详解:单机网络控制怎么做 | 一图详解:防火墙时代的网络边界 | 一图详解:为什么边界会失效 | 从单机到边界:网络控制为什么开始重构 |
| 周二 | 容器网络 | 一图详解:容器化后网络变难了什么 | 一图详解:服务发现为何成为关键 | 一图详解:东西向流量怎么管 | 容器网络为什么把治理压力推向平台层 |
| 周三 | K8s 网络 | 一图详解Kubernetes 网络抽象了什么 | 一图详解Service 和 Ingress 的区别 | 一图详解CNI 和 DNS 的位置 | K8s 网络的真正复杂度在哪里 |
| 周四 | Service Mesh | 一图详解Service Mesh 解决什么问题 | 一图详解:流量治理为什么下沉 | 一图详解mTLS 如何改变信任模型 | Service Mesh 为什么不是可有可无的组件 |
| 周五 | 云原生零信任 | 一图详解:工作负载身份是什么 | 一图详解mTLS 为什么只是开始 | 一图详解:策略执行为什么重要 | 云原生零信任怎么落到基础设施 |
| 周六 | 当下实践 | 一图详解Service Mesh 的局限在哪里 | 一图详解:哪些场景仍需要简化 | 一图详解:如何避免过度复杂化 | 当前阶段应该怎么选、怎么落 |
| 周日 | 总结收束 | 一图详解:网络边界如何演进 | 一图详解:从设备到身份到策略 | 一图详解:云原生网络的下一步 | 从网络边界到服务治理:这一轮演进到底在变什么 |
## 图文内容概要
### 图文 1
- 主题:单机网络控制
- 钩子:早期网络为什么简单,也为什么很快不够用
- 重点:端口、进程、主机连通性
### 图文 2
- 主题:传统网络边界
- 钩子:为什么防火墙时代能解决一部分问题,却解决不了应用复杂度
- 重点ACL、网段、静态策略
### 图文 3
- 主题:容器网络
- 钩子:容器让网络控制复杂度陡增
- 重点:服务发现、东西向流量、弹性伸缩
### 图文 4
- 主题Kubernetes 网络
- 钩子K8s 为什么把网络变成平台能力
- 重点Service、Ingress、Gateway、Policy、DNS、CNI
### 图文 5
- 主题Service Mesh
- 钩子:为什么要把流量治理和身份控制下沉
- 重点mTLS、重试、熔断、可观测性、路由
### 图文 6
- 主题:云原生零信任
- 钩子:网络信任为什么要迁移到身份和策略
- 重点:工作负载身份、细粒度授权、策略执行
### 图文 7
- 主题:当下实践
- 钩子Service Mesh 不是银弹,它适合什么,不适合什么
- 重点:收益、复杂度、局限、选型判断
## 标题建议
### 图文标题统一风格
- 一图详解XXX
- 一图看懂XXX
- 为什么 XXX 会改变云原生网络
### 长文标题建议
- 从单机网络到 Service Mesh云原生网络为什么开始重构
- Service Mesh 为什么不是“多一层网格”,而是治理层迁移
- 云原生网络的下一阶段:从流量控制走向身份和策略
## 适配平台
- 视频平台:适合做 3 到 5 分钟科普视频
- 小红书:适合拆成轮播图 + 收藏型知识卡
- 公众号:适合发布长文主稿
- X适合用总主题 + 单点结论做短帖

View File

@ -323,6 +323,7 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
key: contentKeyInternal,
padding: const EdgeInsets.fromLTRB(10, 8, 10, 0),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

@ -620,30 +620,36 @@ class AssistantLowerPaneInternal extends StatelessWidget {
return ColoredBox(
color: palette.canvas,
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.only(bottom: bottomContentInset),
child: ComposerBarInternal(
controller: controller,
inputController: inputController,
focusNode: focusNode,
thinkingLabel: thinkingLabel,
showModelControl: showModelControl,
modelLabel: modelLabel,
modelOptions: modelOptions,
attachments: attachments,
availableSkills: availableSkills,
selectedSkillKeys: selectedSkillKeys,
onRemoveAttachment: onRemoveAttachment,
onToggleSkill: onToggleSkill,
onThinkingChanged: onThinkingChanged,
onModelChanged: onModelChanged,
onPickAttachments: onPickAttachments,
onAddAttachment: onAddAttachment,
onPasteImageAttachment: onPasteImageAttachment,
onContentHeightChanged: onComposerContentHeightChanged,
onInputHeightChanged: onComposerInputHeightChanged,
onSend: onSend,
child: ClipRect(
child: Padding(
padding: EdgeInsets.only(bottom: bottomContentInset),
child: OverflowBox(
alignment: Alignment.bottomCenter,
minHeight: 0,
maxHeight: double.infinity,
child: ComposerBarInternal(
controller: controller,
inputController: inputController,
focusNode: focusNode,
thinkingLabel: thinkingLabel,
showModelControl: showModelControl,
modelLabel: modelLabel,
modelOptions: modelOptions,
attachments: attachments,
availableSkills: availableSkills,
selectedSkillKeys: selectedSkillKeys,
onRemoveAttachment: onRemoveAttachment,
onToggleSkill: onToggleSkill,
onThinkingChanged: onThinkingChanged,
onModelChanged: onModelChanged,
onPickAttachments: onPickAttachments,
onAddAttachment: onAddAttachment,
onPasteImageAttachment: onPasteImageAttachment,
onContentHeightChanged: onComposerContentHeightChanged,
onInputHeightChanged: onComposerInputHeightChanged,
onSend: onSend,
),
),
),
),
);

View File

@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import '../../i18n/app_language.dart';
import '../../runtime/runtime_models.dart';
class SettingsAccountPanel extends StatelessWidget {
class SettingsAccountPanel extends StatefulWidget {
const SettingsAccountPanel({
super.key,
required this.settings,
@ -48,73 +48,113 @@ class SettingsAccountPanel extends StatelessWidget {
final Future<void> Function() onSync;
final Future<void> Function() onLogout;
@override
State<SettingsAccountPanel> createState() => _SettingsAccountPanelState();
}
class _SettingsAccountPanelState extends State<SettingsAccountPanel>
with SingleTickerProviderStateMixin {
late final TabController _signedOutTabController;
@override
void initState() {
super.initState();
_signedOutTabController = TabController(
length: 2,
vsync: this,
initialIndex: _tabIndexFor(widget.settings),
);
}
@override
void didUpdateWidget(SettingsAccountPanel oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.accountSignedIn != widget.accountSignedIn ||
oldWidget.accountMfaRequired != widget.accountMfaRequired) {
_signedOutTabController.index = _tabIndexFor(widget.settings);
}
}
@override
void dispose() {
_signedOutTabController.dispose();
super.dispose();
}
int _tabIndexFor(SettingsSnapshot settings) {
return settings.acpBridgeServerModeConfig.effective.source == 'bridge'
? 1
: 0;
}
@override
Widget build(BuildContext context) {
if (!accountSignedIn && !accountMfaRequired) {
return DefaultTabController(
length: 2,
initialIndex:
settings.acpBridgeServerModeConfig.effective.source == 'bridge'
? 1
: 0,
child: Column(
children: [
TabBar(
tabs: [
Tab(text: appText('svc.plus 云端同步', 'svc.plus Cloud Sync')),
Tab(text: appText('手动 Bridge 配置', 'Manual Bridge Config')),
],
onTap: (index) {
onSaveAccountProfile(isManualBridge: index == 1);
},
),
const SizedBox(height: 24),
SizedBox(
height: 480,
child: TabBarView(
physics: const NeverScrollableScrollPhysics(),
children: [
_SignedOutAccountPanel(
accountBusy: accountBusy,
accountBaseUrlController: accountBaseUrlController,
accountIdentifierController: accountIdentifierController,
accountPasswordController: accountPasswordController,
onSaveAccountProfile: onSaveAccountProfile,
onLogin: onLogin,
),
_ManualBridgePanel(
settings: settings,
accountBusy: accountBusy,
bridgeUrlController: bridgeUrlController,
bridgeTokenController: bridgeTokenController,
onSaveAccountProfile: onSaveAccountProfile,
),
if (!widget.accountSignedIn && !widget.accountMfaRequired) {
return AnimatedBuilder(
animation: _signedOutTabController,
builder: (context, _) {
return Column(
children: [
TabBar(
controller: _signedOutTabController,
tabs: [
Tab(text: appText('svc.plus 云端同步', 'svc.plus Cloud Sync')),
Tab(text: appText('手动 Bridge 配置', 'Manual Bridge Config')),
],
onTap: (index) {
widget.onSaveAccountProfile(isManualBridge: index == 1);
},
),
),
],
),
const SizedBox(height: 24),
SizedBox(
height: 480,
child: IndexedStack(
index: _signedOutTabController.index,
children: [
_SignedOutAccountPanel(
accountBusy: widget.accountBusy,
accountBaseUrlController: widget.accountBaseUrlController,
accountIdentifierController:
widget.accountIdentifierController,
accountPasswordController:
widget.accountPasswordController,
onSaveAccountProfile: widget.onSaveAccountProfile,
onLogin: widget.onLogin,
),
_ManualBridgePanel(
settings: widget.settings,
accountBusy: widget.accountBusy,
bridgeUrlController: widget.bridgeUrlController,
bridgeTokenController: widget.bridgeTokenController,
onSaveAccountProfile: widget.onSaveAccountProfile,
),
],
),
),
],
);
},
);
}
if (accountMfaRequired) {
if (widget.accountMfaRequired) {
return _PendingMfaAccountPanel(
accountBusy: accountBusy,
accountBaseUrlController: accountBaseUrlController,
accountIdentifierController: accountIdentifierController,
accountMfaCodeController: accountMfaCodeController,
onVerifyMfa: onVerifyMfa,
onCancelMfa: onCancelMfa,
accountBusy: widget.accountBusy,
accountBaseUrlController: widget.accountBaseUrlController,
accountIdentifierController: widget.accountIdentifierController,
accountMfaCodeController: widget.accountMfaCodeController,
onVerifyMfa: widget.onVerifyMfa,
onCancelMfa: widget.onCancelMfa,
);
}
return _SignedInAccountPanel(
settings: settings,
accountSession: accountSession,
accountState: accountState,
accountBusy: accountBusy,
accountStatus: accountStatus,
onSaveAccountProfile: onSaveAccountProfile,
onSync: onSync,
onLogout: onLogout,
settings: widget.settings,
accountSession: widget.accountSession,
accountState: widget.accountState,
accountBusy: widget.accountBusy,
accountStatus: widget.accountStatus,
onSaveAccountProfile: widget.onSaveAccountProfile,
onSync: widget.onSync,
onLogout: widget.onLogout,
);
}
}

View File

@ -474,6 +474,37 @@ void main() {
expect(sendCount, 1);
});
testWidgets('keeps bottom action row visible when pane height is reduced', (
tester,
) async {
final controller = AppController(
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('unit-fixture-task-a');
await tester.pumpWidget(
_buildTestApp(
height: 112,
child: _buildLowerPane(
controller: controller,
inputController: TextEditingController(text: 'hello'),
),
),
);
await tester.pumpAndSettle();
final paneBottom = tester
.getBottomLeft(find.byKey(const Key('assistant-lower-pane-host')))
.dy;
final sendButtonBottom = tester
.getBottomLeft(find.byKey(const Key('assistant-send-button')))
.dy;
expect(sendButtonBottom, lessThanOrEqualTo(paneBottom));
});
testWidgets('groups all visible skills by source', (tester) async {
final toggledKeys = <String>[];
final searchController = TextEditingController();
@ -583,11 +614,18 @@ void main() {
});
}
Widget _buildTestApp({required Widget child}) {
Widget _buildTestApp({required Widget child, double height = 360}) {
return MaterialApp(
theme: AppTheme.light(),
home: Material(
child: Center(child: SizedBox(width: 1400, height: 360, child: child)),
child: Center(
child: SizedBox(
key: const Key('assistant-lower-pane-host'),
width: 1400,
height: height,
child: child,
),
),
),
);
}

View File

@ -64,6 +64,56 @@ void main() {
expect(loginCount, 1);
});
testWidgets('accepts password input on the cloud sign-in form', (
tester,
) async {
final controllers = _TestControllers();
addTearDown(controllers.dispose);
var submittedPassword = '';
await tester.pumpWidget(
_buildTestApp(
child: SettingsAccountPanel(
settings: SettingsSnapshot.defaults(),
accountSession: null,
accountState: null,
accountBusy: false,
accountSignedIn: false,
accountMfaRequired: false,
accountBaseUrlController: controllers.baseUrl,
accountIdentifierController: controllers.identifier,
accountPasswordController: controllers.password,
accountMfaCodeController: controllers.mfaCode,
bridgeUrlController: controllers.bridgeUrl,
bridgeTokenController: controllers.bridgeToken,
onSaveAccountProfile: ({required bool isManualBridge}) async {},
onLogin: () async {
submittedPassword = controllers.password.text;
},
onVerifyMfa: () async {},
onCancelMfa: () async {},
onSync: () async {},
onLogout: () async {},
),
),
);
final passwordField = find.byKey(
const ValueKey('settings-account-password-field'),
);
await tester.tap(passwordField);
await tester.enterText(passwordField, 'typed-password');
await tester.tap(
find.byKey(const ValueKey('settings-account-login-button')),
);
await tester.pump();
expect(controllers.password.text, 'typed-password');
expect(submittedPassword, 'typed-password');
});
testWidgets(
'shows account sync status, resync, and exit in signed-in mode',
(tester) async {