diff --git a/docs/cases/README.md b/docs/cases/README.md index 073892d9..d5354294 100644 --- a/docs/cases/README.md +++ b/docs/cases/README.md @@ -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) ## 配套文档 diff --git a/docs/cases/ai-security-evolution-content-scenario/ai-security-evolution-scenario.pptx b/docs/cases/ai-security-evolution-content-scenario/ai-security-evolution-scenario.pptx index 17804a52..6de69a1d 100644 Binary files a/docs/cases/ai-security-evolution-content-scenario/ai-security-evolution-scenario.pptx and b/docs/cases/ai-security-evolution-content-scenario/ai-security-evolution-scenario.pptx differ diff --git a/docs/cases/ai-security-evolution-content-scenario/wechat-article.md b/docs/cases/ai-security-evolution-content-scenario/wechat-article.md index 2d90126f..ce06cc0b 100644 --- a/docs/cases/ai-security-evolution-content-scenario/wechat-article.md +++ b/docs/cases/ai-security-evolution-content-scenario/wechat-article.md @@ -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 时代的边界迁移。 diff --git a/docs/cases/ai-security-evolution-content-scenario/x-copy.md b/docs/cases/ai-security-evolution-content-scenario/x-copy.md index e316dbe5..1482ca15 100644 --- a/docs/cases/ai-security-evolution-content-scenario/x-copy.md +++ b/docs/cases/ai-security-evolution-content-scenario/x-copy.md @@ -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 权限,是按人、按应用,还是按任务隔离? diff --git a/docs/cases/ai-security-evolution-content-scenario/xhs-copy.md b/docs/cases/ai-security-evolution-content-scenario/xhs-copy.md index 202c6c70..cc568950 100644 --- a/docs/cases/ai-security-evolution-content-scenario/xhs-copy.md +++ b/docs/cases/ai-security-evolution-content-scenario/xhs-copy.md @@ -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 个问题 diff --git a/docs/cases/service-mesh-evolution-video-scenario/README.md b/docs/cases/service-mesh-evolution-video-scenario/README.md new file mode 100644 index 00000000..4b59dcd6 --- /dev/null +++ b/docs/cases/service-mesh-evolution-video-scenario/README.md @@ -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 的演进、能力边界和当前实践。 +- 文案规划能直接拆成图文连载与长文连载,并保持同一主线叙事。 +- 同线程继续追问修改时,仍基于这组图片和视频上下文。 diff --git a/docs/cases/service-mesh-evolution-video-scenario/social-plan.md b/docs/cases/service-mesh-evolution-video-scenario/social-plan.md new file mode 100644 index 00000000..899b4f4d --- /dev/null +++ b/docs/cases/service-mesh-evolution-video-scenario/social-plan.md @@ -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:适合用总主题 + 单点结论做短帖 + diff --git a/lib/features/assistant/assistant_page_composer_bar.dart b/lib/features/assistant/assistant_page_composer_bar.dart index dafb330f..33e5d94c 100644 --- a/lib/features/assistant/assistant_page_composer_bar.dart +++ b/lib/features/assistant/assistant_page_composer_bar.dart @@ -323,6 +323,7 @@ class ComposerBarStateInternal extends State { key: contentKeyInternal, padding: const EdgeInsets.fromLTRB(10, 8, 10, 0), child: Column( + mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/features/assistant/assistant_page_main.dart b/lib/features/assistant/assistant_page_main.dart index 548aead8..8189ff57 100644 --- a/lib/features/assistant/assistant_page_main.dart +++ b/lib/features/assistant/assistant_page_main.dart @@ -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, + ), + ), ), ), ); diff --git a/lib/features/settings/settings_account_panel.dart b/lib/features/settings/settings_account_panel.dart index 398c3239..9b78ab95 100644 --- a/lib/features/settings/settings_account_panel.dart +++ b/lib/features/settings/settings_account_panel.dart @@ -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 Function() onSync; final Future Function() onLogout; + @override + State createState() => _SettingsAccountPanelState(); +} + +class _SettingsAccountPanelState extends State + 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, ); } } diff --git a/test/features/assistant/assistant_lower_pane_test.dart b/test/features/assistant/assistant_lower_pane_test.dart index 7c35aaee..042870ca 100644 --- a/test/features/assistant/assistant_lower_pane_test.dart +++ b/test/features/assistant/assistant_lower_pane_test.dart @@ -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 {}, + ); + 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 = []; 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, + ), + ), ), ); } diff --git a/test/features/settings/settings_account_panel_test.dart b/test/features/settings/settings_account_panel_test.dart index 4354ebbf..a67d5b8e 100644 --- a/test/features/settings/settings_account_panel_test.dart +++ b/test/features/settings/settings_account_panel_test.dart @@ -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 {