Polish assistant UI and add Service Mesh video case
This commit is contained in:
parent
25e8b17dd8
commit
31990b30d9
@ -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)
|
||||
|
||||
## 配套文档
|
||||
|
||||
|
||||
Binary file not shown.
@ -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 时代的边界迁移。
|
||||
|
||||
@ -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 权限,是按人、按应用,还是按任务隔离?
|
||||
|
||||
@ -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 个问题
|
||||
|
||||
45
docs/cases/service-mesh-evolution-video-scenario/README.md
Normal file
45
docs/cases/service-mesh-evolution-video-scenario/README.md
Normal 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 的演进、能力边界和当前实践。
|
||||
- 文案规划能直接拆成图文连载与长文连载,并保持同一主线叙事。
|
||||
- 同线程继续追问修改时,仍基于这组图片和视频上下文。
|
||||
@ -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:适合用总主题 + 单点结论做短帖
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user