diff --git a/README.md b/README.md index 6d494325..78d2bbf2 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,14 @@ between this live metadata and control-plane information without exposing prompt or credentials. The same metadata advertises mounted smart-search sources, so Studio can disable unavailable sources up front and query the Agent's web-search tool, KnowledgeBase, or long-term memory without exposing component credentials. +Studio also provides an isolated Insight Sandbox for temporary Codex +conversations. It reuses a dedicated AgentKit CodeEnv tool, creates a fresh +user-owned Sandbox session, and deletes that session on exit without adding the +conversation to normal Studio history. Reloading may create another temporary +session; AgentKit reclaims abandoned sessions automatically when their TTL ends. +When configuring skills, Studio can also browse account-scoped AgentKit Skill +Spaces and their paginated skill lists by region and project. These requests are +signed on the server, so browser clients never receive Volcengine credentials. The Studio deployment flow lists Feishu, knowledge-base, short-/long-term memory, and observability settings in their feature sections. Values entered @@ -198,13 +206,17 @@ VeADK provides several useful command line tools for faster deployment and optim `--region`, automatically locate the Identity user pool across Beijing and Shanghai, and select the VeFaaS project with `--project` (default `default`); custom local or remote logo images are bundled into the deployment; the - deployed client skips the second OAuth consent confirmation after login + deployed client skips the second OAuth consent confirmation after login; + two dedicated AgentKit CodeEnv Tools are created automatically for temporary + chats and Skill creation unless their IDs are supplied with + `--sandbox-chat-codex-tool-id` and `--sandbox-skill-creator-tool-id` - `veadk studio update --vefaas-app-name `: build the frontend from a local VeADK source checkout and release it through the existing VeFaaS Application and Function. Omit `--region` and `--project` to search Beijing, Shanghai, and all visible projects. Existing URL, SSO, IAM, gateway, environment variables, title, and logo are preserved; pass `--site-title` or - `--site-logo` only when those branding values should be replaced + `--site-logo` only when those branding values should be replaced. Sandbox Tool + IDs are also preserved unless the corresponding deploy option is supplied Studio can assign comma-separated local usernames or OAuth emails to the `admin` and `developer` roles: diff --git a/docs/content/docs/framework/frontend.en.mdx b/docs/content/docs/framework/frontend.en.mdx index 47c3b614..bc523132 100644 --- a/docs/content/docs/framework/frontend.en.mdx +++ b/docs/content/docs/framework/frontend.en.mdx @@ -19,14 +19,28 @@ VeADK ships a React frontend that renders [A2UI](/en/docs/framework/a2ui) (agent and replay both user and model media from history. Chat images use compact thumbnails and open in a zoomable full-screen viewer. - **Skill and sub-agent invocation**: type `/` to select a mounted skill or `@` to route the turn to an eligible sub-agent. +- **New-chat modes**: retain the Agent conversation path, start a temporary + Codex Agent conversation in an AgentKit Sandbox, or generate two real Skill + candidates in isolated AgentKit CodeEnv sessions for comparison, ZIP + download, and AgentKit publishing. - **Agent picker**: switch agents from the top-left; long lists scroll within the viewport, and hovering an agent shows its model and mounted tools. - **Session history**: auto-saved, time-sorted, reopen or delete. +- **Temporary session**: start an isolated temporary AgentKit Sandbox from the + new-chat composer. Studio reuses its dedicated CodeEnv tool, + creates a fresh user-owned Sandbox session, and runs multi-turn Codex chat + with live public reasoning summaries, commands, file changes, MCP calls, and + final replies. + Exiting deletes the cloud session without writing it to normal chat history; + after a reload, users can start another session and AgentKit reclaims the + abandoned session automatically when its TTL ends. - **Smart search**: the *Session* source full-text-searches the current agent's history; the *Web* source calls the agent's mounted web-search tool live (using credentials from the server's environment variables). - **Add an AgentKit agent**: paste a URL + API key to connect a remote agent over the ADK protocol; it then appears in the picker. - **Custom-agent workbench**: author system prompts in a rich Markdown editor with heading and list shortcuts. Local skills accept a dropped folder or ZIP - and detect the format automatically. Then debug the agent beside its configuration. + and detect the format automatically. You can also browse account-scoped + AgentKit Skill Spaces and their skills by region and project. Then debug the + agent beside its configuration. Initialization, session, and conversation failures show credential-redacted error details and Runner logs that can be expanded and copied. The deployment page shows the Agent topology on the left, with hover details, configuration @@ -45,6 +59,14 @@ credentials to local debug runs and deployed AgentKit runtimes, allowing VeADK to resolve Ark, embedding, media, speech, VeSearch, and APMPlus keys without asking for them again. +Insight Sandbox requires server-side `VOLCENGINE_ACCESS_KEY`, +`VOLCENGINE_SECRET_KEY`, `MODEL_AGENT_API_KEY`, and `MODEL_AGENT_NAME` values. +Volcengine credentials, model credentials, and the authorized Sandbox endpoint +remain on the Studio server and are never returned to the browser. +Temporary Sandbox state is process-local. Run Studio with one server process, +or configure session affinity so create, message, and exit requests from one +browser reach the same instance. + When the Feishu channel is enabled or disabled on the deployment page, Studio regenerates the project so `app.py`, the `extensions` dependency, and runtime environment variables stay aligned before deployment. @@ -146,6 +168,70 @@ After selecting a sub-agent, the `/` menu shows skills mounted on that target. C The frontend sends structured `veadkInvocation` metadata instead of inferring invocation intent from the message string. The backend plugin directs ADK to call the skill tool or invoke `transfer_to_agent` one tree edge at a time until it reaches the target. The same metadata is written to the first Google GenAI `Part`, so reopening session history restores the `/skill` and `@agent` chips. +### Skill creation mode + +The new-chat **Skill creation** mode runs +`doubao-seed-2-0-pro-260215` and `deepseek-v4-flash-260425` in parallel through +Ark Responses. Each candidate gets an independent AgentKit CodeEnv session. +The server validates the generated directory name, `SKILL.md` frontmatter, +file count, size, and safe paths before packaging a ZIP. AgentKit credential +hosting keeps the real Ark API key; Studio and Sandbox receive only its +revocable gateway ticket. Per-job Session variables cannot override that +hosted ticket, and Studio accepts only Volcengine HTTPS credential relay URLs. +Model credentials are never returned to the browser, and the Skill creation +APIs require the Studio developer or admin role. + +After submission, both candidate conversations appear immediately and +independently render public reasoning summaries, tool calls, and assistant +messages returned by the generator. Private chain-of-thought and credentials +never enter the page. Completed candidates still support preview, ZIP download, +and AgentKit publishing. Once the real job ID is available, the frontend resumes +polling directly from Sandbox state. An interrupted creation stream or a brief +AgentKit control-plane delay no longer turns an intermediate state into an +expired job. + +Before starting a local server, configure separate `Ready` CodeEnv Tools for +temporary chats and Skill creation: + +```bash +export SANDBOX_CHAT_CODEX= +export SANDBOX_SKILL_CREATOR= +veadk frontend --agents-dir examples +``` + +For local Studio, run the AgentKit `credential-hosting` command and choose to +write its result into both dedicated CodeEnv Tools. Adding a +candidate to AgentKit uploads its ZIP to TOS, then creates or updates the Skill +through the Skills API. It is published to a SkillSpace only when the user +provides SkillSpace IDs. + +| Environment variable | Default | Description | +| :-- | :-- | :-- | +| `SANDBOX_CHAT_CODEX` | — | AgentKit CodeEnv Tool ID dedicated to temporary chats. | +| `SANDBOX_SKILL_CREATOR` | — | AgentKit CodeEnv Tool ID dedicated to Skill creation. | +| `VEADK_SKILL_CREATOR_TOS_BUCKET` | AgentKit account default | TOS bucket used for published artifacts. | +| `VEADK_SKILL_CREATOR_TOS_PREFIX` | `agentkit/skills` | TOS object-key prefix. | +| `VEADK_SKILL_CREATOR_PROJECT_NAME` | — | Project name used when creating a Skill. | + +Candidate sessions have a 30-minute TTL and are deleted immediately when the +user starts over or leaves the job. Job state is stored in Sandbox rather than +frontend-process memory, so polling and downloads continue to work when FaaS +requests reach different instances. + +On the first cloud deployment, the command creates two independent CodeEnv +Tools with the deployer's AK/SK when Tool IDs are omitted. Existing Tools can be +selected with `--sandbox-chat-codex-tool-id` and +`--sandbox-skill-creator-tool-id`. AgentKit credential hosting stores the Ark +credential in KMS and binds only the relay URL and revocable ticket to both +Tools. The VeFaaS Function receives only Tool IDs, never model credentials: + +```bash +veadk studio deploy \ + --user-pool-id \ + --allowed-client-id \ + --vefaas-app-name +``` + ### Custom Studio branding Use `--site-title` for a system name of up to six characters and `--site-logo` @@ -199,6 +285,9 @@ and `--site-logo` replace cloud branding only when explicitly provided; otherwise the current values are retained. Use `--path` to select a source checkout; it defaults to the current directory. Node.js and npm must be installed locally. +`--sandbox-chat-codex-tool-id` and `--sandbox-skill-creator-tool-id` likewise +replace only the explicitly supplied environment value. Omitting either option +keeps that Tool ID from the deployed Function instead of clearing it. When deployment registers the callback URL, it keeps the VeIdentity login page enabled and enables skip-consent for that client. This avoids showing a second diff --git a/docs/content/docs/framework/frontend.mdx b/docs/content/docs/framework/frontend.mdx index aced2e79..9d8af9f9 100644 --- a/docs/content/docs/framework/frontend.mdx +++ b/docs/content/docs/framework/frontend.mdx @@ -18,12 +18,19 @@ VeADK 自带一个 React 前端,用于渲染智能体经由 Google ADK API Ser - **多模态消息**:上传图片、TXT/Markdown、PDF 与视频;用户附件和模型返回的媒体均支持预览与历史回放。 对话中的图片默认以紧凑尺寸展示,点击后可全屏缩放预览。 - **技能与子 Agent 调用**:在输入框输入 `/` 选择挂载技能,输入 `@` 将本轮交给可选的子 Agent。 +- **新会话模式**:保留 Agent 对话模式;临时会话会在 AgentKit Sandbox 中启动 Codex Agent;Skill 创建模式会在两个独立的 AgentKit CodeEnv Session 中使用固定模型做真实 A/B 生成,完成后可对比、下载 ZIP 或添加到 AgentKit。 - **Agent 选择器**:左上角切换 agent;长列表在视口内独立滚动,悬停可查看该 agent 的模型与挂载的工具。 - **历史会话**:自动保存、按时间排序,可重新打开或删除。 +- **临时会话**:从新会话输入框启动独立的 AgentKit 沙箱临时会话。 + Studio 会复用专属 CodeEnv Tool、为当前用户创建全新的 Sandbox Session,并通过 + Codex 进行多轮对话,实时展示公开思考摘要、命令、文件修改、MCP 调用和最终回复。 + 退出后云端 Session 会被删除,内容不会写入普通历史会话; + 页面刷新后可直接新建,遗留 Session 由 AgentKit 在 TTL 到期后自动回收。 - **智能搜索**:「会话」源在当前 agent 的历史消息中做全文检索;「网页」源调用该 agent 挂载的联网搜索工具实时检索(使用服务端环境变量里的凭据)。 - **添加 AgentKit 智能体**:填入访问地址 + API Key,按 ADK 协议接入远程 agent,接入后出现在选择器中。 - **自定义 Agent 工作台**:系统提示词支持所见即所得的 Markdown - 编辑与标题、列表等快捷输入;本地技能可直接拖入文件夹或 ZIP,格式会自动识别。 + 编辑与标题、列表等快捷输入;本地技能可直接拖入文件夹或 ZIP,格式会自动识别, + 也可按地域和项目浏览账号下的 AgentKit Skill Space 及其中的技能。 完成配置后可在同页调试。调试初始化、 会话或对话失败时会展示经过凭据脱敏的具体错误和 Runner 日志, 并支持展开与复制。部署页左侧展示 Agent 拓扑,悬停可核对节点信息与配置, @@ -39,6 +46,13 @@ VeADK 自带一个 React 前端,用于渲染智能体经由 Google ADK API Ser VeADK 会据此获取 Ark、Embedding、图像、视频、语音、VeSearch 和 APMPlus 的 API Key,挂载组件时无需重复填写。 +“灵光一现”需要 Studio 服务端配置 `VOLCENGINE_ACCESS_KEY`、 +`VOLCENGINE_SECRET_KEY`、`MODEL_AGENT_API_KEY` 和 `MODEL_AGENT_NAME`。 +火山引擎凭证、模型凭证以及带授权信息的 Sandbox Endpoint 始终保留在服务端, +不会返回浏览器。 +临时沙箱状态保存在 Studio 进程内;部署时应使用单个服务进程,或配置会话亲和, +确保同一浏览器的创建、对话和退出请求到达同一个实例。 + 在部署页打开或关闭飞书渠道时,Studio 会重新生成项目,确保 `app.py`、 `extensions` 依赖和运行时环境变量在部署前保持一致。 @@ -134,6 +148,59 @@ veadk frontend --agents-dir examples 前端不会从消息字符串中猜测调用意图,而是发送结构化的 `veadkInvocation` metadata。后端插件据此要求 ADK 调用技能工具,或沿 Agent 树逐级调用 `transfer_to_agent`,直到到达目标节点。同一份 metadata 也会写入首个 Google GenAI `Part`,因此重新加载历史会话后仍能恢复 `/skill` 与 `@agent` chip。 +### Skill 创建模式 + +新会话输入框中的「Skill 创建」会固定使用 +`doubao-seed-2-0-pro-260215` 和 `deepseek-v4-flash-260425`,通过 Ark +Responses API 并行生成两个候选方案。每个候选使用独立的 AgentKit CodeEnv +Session,生成结果会校验目录名、`SKILL.md` frontmatter、文件数量、大小和路径安全, +再打包为 ZIP。真实 Ark API Key 由 AgentKit 凭据托管保存,Studio 和 Sandbox +只使用可撤销的网关票据;每个任务的 Session 环境变量不会覆盖托管票据,且服务端 +只接受 Volcengine HTTPS 凭据中继地址。浏览器不会收到模型凭据。Skill 创建接口仅允许 +Studio developer 和 admin 使用。 + +提交需求后会立即显示两个候选对话流,各自持续渲染生成器返回的公开思考摘要、工具 +调用和助手回复。私有思维链和凭据不会进入页面。候选完成后仍可预览、下载 ZIP 或 +添加到 AgentKit。前端拿到真实任务 ID 后会直接从 Sandbox 状态恢复轮询;创建流中断 +或 AgentKit 控制面短暂未发现新 Session 时,不会把中间状态误判为任务过期。 + +本地启动前需要分别配置临时会话和 Skill 创建使用的两个 `Ready` CodeEnv Tool: + +```bash +export SANDBOX_CHAT_CODEX= +export SANDBOX_SKILL_CREATOR= +veadk frontend --agents-dir examples +``` + +本地运行前使用 AgentKit 的 `credential-hosting` 命令,并将结果分别写入上述 CodeEnv +Tool。添加到 AgentKit 时,服务端会先将 +ZIP 上传到 TOS,再调用 Skills API 创建或更新 Skill;只有用户填写 SkillSpace ID +时才发布到对应空间。以下变量均为可选覆盖项: + +| 环境变量 | 默认值 | 说明 | +| :-- | :-- | :-- | +| `SANDBOX_CHAT_CODEX` | — | 临时会话专用 AgentKit CodeEnv Tool ID。 | +| `SANDBOX_SKILL_CREATOR` | — | Skill 创建专用 AgentKit CodeEnv Tool ID。 | +| `VEADK_SKILL_CREATOR_TOS_BUCKET` | AgentKit 账户默认 Bucket | 发布产物使用的 TOS Bucket。 | +| `VEADK_SKILL_CREATOR_TOS_PREFIX` | `agentkit/skills` | TOS 对象 Key 前缀。 | +| `VEADK_SKILL_CREATOR_PROJECT_NAME` | — | 创建 Skill 时使用的项目名称。 | + +候选 Session 的 TTL 为 30 分钟,用户重新创建或离开任务时会立即清理。任务状态保存 +在 Sandbox 而非 Frontend 进程内存,因此 FaaS 请求命中不同实例时仍可继续轮询和下载。 + +云上首次部署时,若没有指定 Tool ID,部署命令会使用部署 AK/SK 自动创建两个独立 +CodeEnv Tool。也可以分别使用 `--sandbox-chat-codex-tool-id` 和 +`--sandbox-skill-creator-tool-id` 指定已有 Tool。部署命令会通过 AgentKit 凭据托管 +将 Ark 凭据保存到 KMS,并把代理地址和可撤销票据绑定到两个 Tool;VeFaaS Function +只接收 Tool ID,不接收模型凭据: + +```bash +veadk studio deploy \ + --user-pool-id \ + --allowed-client-id \ + --vefaas-app-name +``` + ### 自定义 Studio 品牌 使用 `--site-title` 设置不超过 6 个字符的系统名称,使用 @@ -179,6 +246,8 @@ Application。未指定 `--region` 和 `--project` 时,会在北京、上海 更新不会改变 Application/Function ID、访问 URL、SSO、IAM、网关或已有环境 变量。`--site-title` 和 `--site-logo` 仅在显式传入时覆盖,省略时保留云上品牌 设置。可用 `--path` 指定源码目录,默认为当前目录;本地需安装 Node.js 与 npm。 +`--sandbox-chat-codex-tool-id` 和 `--sandbox-skill-creator-tool-id` 也只在显式传入时 +更新对应环境变量;未传的 Tool ID 保留云上已有值,不会被清空。 部署注册回调地址时会保留 VeIdentity 登录页,并为该客户端开启跳过授权确认, 避免用户登录后再次确认授权。 diff --git a/frontend/README.md b/frontend/README.md index 8cc1d1d9..d2b0b986 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -13,6 +13,12 @@ server that `veadk frontend` launches — no separate backend. - **Composer invocations**: type `/` to select a mounted skill or `@` to route the turn to a mentionable sub-agent. New conversations address the selected Agent by its display name in the composer placeholder. +- **New-chat modes**: keep the existing Agent conversation path, start a + temporary Codex conversation in an AgentKit Sandbox, or create a Skill with + a real two-model A/B run in independent AgentKit CodeEnv sessions. Skill + progress resumes from Sandbox state if the creation stream is interrupted; + completed candidates can be compared, downloaded as ZIP files, and added to + AgentKit. - **Reasoning & tool calls** shown inline (collapsible "thinking", tool blocks). - **Built-in tool activity** gives web search, image/video generation, memory, and knowledge-base retrieval their own repository-drawn icons and concise @@ -23,7 +29,17 @@ server that `veadk frontend` launches — no separate backend. when its session metadata appears. The page header follows the active session's first user message, while long titles truncate without shifting header actions. Session IDs use normal text with a copy action, and sidebar title tooltips show - the full conversation name. Long Agent lists stay within the viewport and scroll independently. + the full conversation name. Long Agent lists stay within the viewport and + scroll independently. +- **Insight Sandbox**: start an isolated, temporary AgentKit CodeEnv session + from the new-chat composer or global header. Studio reuses its dedicated + Sandbox tool, creates a fresh user-owned session, streams Codex reasoning, + tool activity, and replies into the normal conversation renderer, and deletes + the cloud session on exit without adding it to normal chat history. + Reloading can create another session; AgentKit reclaims abandoned sessions + automatically when their TTL ends. +- **AgentKit Skill center**: browse Skill Spaces and their skills with + server-side pagination by region, then inspect the selected Skill content. - **Tracing viewer**: a span tree + detail panel from the ADK debug trace. - **Smart search**: search sessions, the network through `web_search`, and a selected Agent's KnowledgeBase or long-term memory when mounted. The source @@ -58,6 +74,15 @@ Changing the Feishu channel on the deployment page regenerates the project so `app.py`, the `extensions` dependency, and the runtime environment variables stay aligned before deployment. +Insight Sandbox requires server-side `VOLCENGINE_ACCESS_KEY`, +`VOLCENGINE_SECRET_KEY`, `MODEL_AGENT_API_KEY`, and `MODEL_AGENT_NAME` values. +These credentials and the AgentKit session endpoint remain on the Studio server +and are never returned to the browser. + +Temporary Sandbox state is process-local. Run Studio with one server worker, or +configure session affinity so create, message, and delete requests from the same +browser reach the same instance. + ## Development specification All frontend changes must follow [`SPEC.md`](SPEC.md). It defines the required @@ -224,6 +249,55 @@ tool or transfer one tree edge at a time until it reaches the selected agent. The same metadata is attached to the first Google GenAI `Part`, so session history restores the `/skill` and `@agent` chips after a reload. +### Skill creation mode + +Skill creation uses `doubao-seed-2-0-pro-260215` and +`deepseek-v4-flash-260425` through Ark Responses. The real Ark API key is kept +by AgentKit credential hosting; Studio and the two isolated Sandbox sessions +receive only its revocable gateway ticket. Credentials are never returned to +the browser or copied into per-job Session variables. Studio also rejects +non-HTTPS or non-Volcengine credential relay URLs. Skill creation is limited +to Studio developers and admins. + +After submission, Studio opens both candidate conversations immediately. Each +conversation independently renders public reasoning summaries, tool calls, and +assistant messages returned by the generator. Private chain-of-thought and +credentials never enter the UI. Completed candidates still support preview, +ZIP download, and AgentKit publish. + +Configure separate ready AgentKit `CodeEnv` Tools for temporary chats and Skill +creation before starting the server. The Tool IDs are intentionally server-only +and cannot be supplied by the browser: + +```bash +export SANDBOX_CHAT_CODEX= +export SANDBOX_SKILL_CREATOR= +veadk frontend --agents-dir examples +``` + +Publishing a generated Skill uses TOS and the AgentKit Skills API. Set +`VEADK_SKILL_CREATOR_TOS_BUCKET`, `VEADK_SKILL_CREATOR_TOS_PREFIX`, and +`VEADK_SKILL_CREATOR_PROJECT_NAME` only when their defaults are unsuitable. +Each candidate session expires after 30 minutes and is deleted immediately when +a job is discarded. Job state lives in Sandbox rather than frontend-process +memory, so polling and downloads continue to work when FaaS requests reach a +different instance. + +For local Studio, run the AgentKit `credential-hosting` command and bind its +result to both CodeEnv Tools. A cloud deployment creates both Tools when their +IDs are omitted. Alternatively, select existing Tools with +`--sandbox-chat-codex-tool-id` and `--sandbox-skill-creator-tool-id`. The deploy +command obtains the Ark key with the deployer's Volcengine credentials, stores +it through AgentKit credential hosting, and binds only the returned ticket and +relay URL to the Tools: + +```bash +veadk studio deploy \ + --user-pool-id \ + --allowed-client-id \ + --vefaas-app-name +``` + ## Agent naming Studio validates every root and nested Agent name against Google ADK rules. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 10df4b45..df517beb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -75,6 +75,25 @@ import type { AgentDraft } from "./create/types"; import type { DeploymentTaskUpdate } from "./ui/ProjectPreview"; import { DeploymentErrorMessage } from "./ui/DeploymentErrorMessage"; import { TextShimmer } from "./ui/text-shimmer/TextShimmer"; +import { createSkillJob, deleteSkillJob } from "./ui/skill-create/api"; +import { SkillCreateWorkspace } from "./ui/skill-create/SkillCreateWorkspace"; +import { SKILL_MODELS, type SkillCreationJob } from "./ui/skill-create/types"; +import type { NewChatMode } from "./ui/new-chat-modes/types"; +import { + sandboxClient, + type SandboxSession as SandboxSessionInfo, +} from "./adk/sandbox"; +import { + getSandboxCapability, + getSkillCreatorCapability, +} from "./adk/newChatCapabilities"; +import { + SandboxLaunchDialog, + type SandboxLaunchState, +} from "./ui/SandboxLaunchDialog"; +import { + SandboxSessionWarning, +} from "./ui/SandboxSession"; import defaultSiteLogo from "./assets/volcengine.svg"; // Breadcrumb root label for the create flow and the per-mode leaf labels. @@ -537,6 +556,16 @@ export default function App() { const creatingSessionRef = useRef | null>(null); const [initializingSession, setInitializingSession] = useState(false); const [pendingTurns, setPendingTurns] = useState([]); + const [sandboxSession, setSandboxSession] = + useState(null); + const [sandboxTurns, setSandboxTurns] = useState([]); + const [sandboxBusy, setSandboxBusy] = useState(false); + const [sandboxLaunchOpen, setSandboxLaunchOpen] = useState(false); + const [sandboxLaunchState, setSandboxLaunchState] = + useState("confirm"); + const [sandboxLaunchError, setSandboxLaunchError] = useState(""); + const sandboxLaunchAbortRef = useRef(null); + const sandboxMessageAbortRef = useRef(null); // Turns are stored PER SESSION, so a background stream can keep updating its // own session's transcript while you view another one — no cross-session // leak, no data loss, and no re-fetch when you switch back (its entry is @@ -544,11 +573,16 @@ export default function App() { const [turnsBySession, setTurnsBySession] = useState>( {}, ); - const turns = sessionId ? turnsBySession[sessionId] ?? [] : pendingTurns; - const conversationTitle = activeSessionTitle( - sessions.find((session) => session.id === sessionId), - turns, - ); + const persistentTurns = sessionId + ? turnsBySession[sessionId] ?? [] + : pendingTurns; + const turns = sandboxSession ? sandboxTurns : persistentTurns; + const conversationTitle = sandboxSession + ? "灵光一现" + : activeSessionTitle( + sessions.find((session) => session.id === sessionId), + turns, + ); const setTurnsFor = ( sid: string, updater: Turn[] | ((prev: Turn[]) => Turn[]), @@ -558,6 +592,14 @@ export default function App() { [sid]: typeof updater === "function" ? updater(m[sid] ?? []) : updater, })); const [input, setInput] = useState(""); + const [newChatMode, setNewChatMode] = useState("agent"); + const [newChatCapabilities, setNewChatCapabilities] = useState<{ + temporaryEnabled?: boolean; + skillCreateEnabled?: boolean; + }>({}); + const [skillJob, setSkillJob] = useState(null); + const [skillCreating, setSkillCreating] = useState(false); + const skillCreationRunRef = useRef(0); const [attachments, setAttachments] = useState([]); const [invocation, setInvocation] = useState(emptyInvocation); const [agentInfo, setAgentInfo] = useState(null); @@ -627,6 +669,9 @@ export default function App() { // per-session maps above. const busy = streamingSids.has(sessionId); const conversationBusy = busy || initializingSession; + const activeConversationBusy = sandboxSession + ? sandboxBusy + : conversationBusy; const activeAgent = activeAgentBySession[sessionId] ?? ""; const seenAgents = seenAgentsBySession[sessionId] ?? EMPTY_STRING_SET; const execPath = execPathBySession[sessionId] ?? EMPTY_STRING_ARR; @@ -651,6 +696,18 @@ export default function App() { } } + function discardSkillCreation() { + skillCreationRunRef.current += 1; + const job = skillJob; + setSkillJob(null); + setSkillCreating(false); + if (job && !job.id.startsWith("pending-")) { + void deleteSkillJob(job.id).catch((cause) => { + setError(cause instanceof Error ? cause.message : String(cause)); + }); + } + } + async function abandonDraftSession(sid: string) { try { await deleteSessionMedia(appName, userId, sid); @@ -828,6 +885,33 @@ export default function App() { resolveAuth(); }, [resolveAuth]); + useEffect(() => { + if (localMode && userId) setLocalUser(userId); + }, [localMode, userId]); + + useEffect(() => { + if (authStatus !== "authenticated" || !userId) { + setNewChatCapabilities({}); + return; + } + let cancelled = false; + void Promise.allSettled([ + getSandboxCapability(), + getSkillCreatorCapability(), + ]).then(([sandboxResult, skillResult]) => { + if (cancelled) return; + setNewChatCapabilities({ + temporaryEnabled: + sandboxResult.status === "fulfilled" && sandboxResult.value.enabled, + skillCreateEnabled: + skillResult.status === "fulfilled" && skillResult.value.enabled, + }); + }); + return () => { + cancelled = true; + }; + }, [authStatus, userId]); + useEffect(() => { if (authStatus !== "authenticated" || !userId) { setAccess(null); @@ -1017,6 +1101,13 @@ export default function App() { () => () => streamAbortsRef.current.forEach((c) => c.abort()), [], ); + useEffect( + () => () => { + sandboxLaunchAbortRef.current?.abort(); + sandboxMessageAbortRef.current?.abort(); + }, + [], + ); // When the app (or resolved user) changes, list existing sessions. On the // very first resolve, restore the previously-open session (if it still @@ -1077,13 +1168,168 @@ export default function App() { } } + function openSandboxLaunch() { + if (sandboxSession) return; + setError(""); + setSandboxLaunchError(""); + setSandboxLaunchState("confirm"); + setSandboxLaunchOpen(true); + } + + function cancelSandboxLaunch() { + sandboxLaunchAbortRef.current?.abort(); + sandboxLaunchAbortRef.current = null; + setSandboxLaunchOpen(false); + setSandboxLaunchState("confirm"); + setSandboxLaunchError(""); + if (!sandboxSession && newChatMode === "temporary") { + setNewChatMode("agent"); + } + } + + async function launchSandboxSession() { + sandboxLaunchAbortRef.current?.abort(); + const controller = new AbortController(); + sandboxLaunchAbortRef.current = controller; + setSandboxLaunchState("loading"); + setSandboxLaunchError(""); + try { + const nextSession = await sandboxClient.startSession({ + signal: controller.signal, + }); + if (sandboxLaunchAbortRef.current !== controller) return; + viewSidRef.current = ""; + setSessionId(""); + setPendingTurns([]); + setInput(""); + setInvocation(emptyInvocation()); + setNewChatMode("temporary"); + discardSkillCreation(); + setSkillCreating(false); + discardDraftAttachments(attachments); + setAttachments([]); + setSandboxTurns([]); + setSandboxSession(nextSession); + setCreateView(null); + setSkillCenter(false); + setAddAgent(false); + setAddMenu(false); + setSearchView(false); + setManageAgents(false); + setSandboxLaunchOpen(false); + setSandboxLaunchState("confirm"); + } catch (launchError) { + if ((launchError as Error)?.name === "AbortError") return; + if (sandboxLaunchAbortRef.current !== controller) return; + setSandboxLaunchError( + launchError instanceof Error + ? launchError.message + : String(launchError), + ); + setSandboxLaunchState("error"); + } finally { + if (sandboxLaunchAbortRef.current === controller) { + sandboxLaunchAbortRef.current = null; + } + } + } + + function exitSandboxSession() { + sandboxMessageAbortRef.current?.abort(); + sandboxMessageAbortRef.current = null; + setSandboxBusy(false); + setSandboxTurns([]); + setInput(""); + setError(""); + setNewChatMode("agent"); + const closingSession = sandboxSession; + setSandboxSession(null); + if (closingSession) { + void sandboxClient + .closeSession(closingSession.id) + .catch((closeError) => setError(String(closeError))); + } + } + + async function sendSandboxMessage(text: string) { + const activeSession = sandboxSession; + if (!activeSession || sandboxBusy || !text.trim()) return; + setError(""); + const controller = new AbortController(); + sandboxMessageAbortRef.current?.abort(); + sandboxMessageAbortRef.current = controller; + const optimisticTurns: Turn[] = [ + { + role: "user", + blocks: [{ kind: "text", text }], + meta: { ts: Date.now() / 1000 }, + }, + { role: "assistant", blocks: [] }, + ]; + setSandboxTurns((current) => [...current, ...optimisticTurns]); + setSandboxBusy(true); + try { + const reply = await sandboxClient.sendMessage( + { sessionId: activeSession.id, text }, + { + signal: controller.signal, + onBlocks: (blocks) => { + if (sandboxMessageAbortRef.current !== controller) return; + setSandboxTurns((current) => { + const next = current.slice(); + const last = next[next.length - 1]; + if (last?.role === "assistant") { + next[next.length - 1] = { ...last, blocks }; + } + return next; + }); + }, + }, + ); + if (sandboxMessageAbortRef.current !== controller) return; + setSandboxTurns((current) => { + const next = current.slice(); + const last = next[next.length - 1]; + if (last?.role === "assistant") { + next[next.length - 1] = { + ...last, + blocks: reply.blocks, + meta: { ts: Date.now() / 1000 }, + }; + } + return next; + }); + } catch (messageError) { + if ((messageError as Error)?.name === "AbortError") return; + if (sandboxMessageAbortRef.current !== controller) return; + setSandboxTurns((current) => current.slice(0, -2)); + setInput(text); + setError( + `临时会话发送失败:${ + messageError instanceof Error + ? messageError.message + : String(messageError) + }`, + ); + } finally { + if (sandboxMessageAbortRef.current === controller) { + sandboxMessageAbortRef.current = null; + setSandboxBusy(false); + } + } + } + // Reset to a fresh, not-yet-created chat. The backend session is created // lazily on the first message (see send()). A background stream (if any) // keeps running and persisting — its writes are suppressed here by viewSidRef. function startNewChat() { + exitSandboxSession(); setError(""); setGreeting(pickGreeting()); - const abandonedSession = sessionId && turns.length === 0 && attachments.length > 0 + setNewChatMode("agent"); + discardSkillCreation(); + setSkillCreating(false); + const abandonedSession = sessionId && persistentTurns.length === 0 && attachments.length > 0 ? sessionId : ""; viewSidRef.current = ""; @@ -1114,11 +1360,14 @@ export default function App() { } async function pickSession(id: string) { + if (sandboxSession) exitSandboxSession(); if (id === sessionId) return; viewSidRef.current = id; setError(""); setInitializingSession(false); setPendingTurns([]); + setNewChatMode("agent"); + discardSkillCreation(); setInvocation(emptyInvocation()); setSessionId(id); // Already have this session's turns (it's cached, or streaming in the @@ -1501,6 +1750,7 @@ export default function App() { startNewChat(); }} onSearch={() => { + if (sandboxSession) exitSandboxSession(); setCreateView(null); setSkillCenter(false); setAddAgent(false); @@ -1514,6 +1764,7 @@ export default function App() { setError("当前账号没有添加 Agent 的权限。"); return; } + if (sandboxSession) exitSandboxSession(); // "添加 Agent" — open the two-card chooser. Drop any selected session. viewSidRef.current = ""; setSessionId(""); @@ -1527,6 +1778,7 @@ export default function App() { setError(""); }} onSkillCenter={() => { + if (sandboxSession) exitSandboxSession(); setCreateView(null); setAddAgent(false); setAddMenu(false); @@ -1540,6 +1792,7 @@ export default function App() { setError("当前账号没有添加 Agent 的权限。"); return; } + if (sandboxSession) exitSandboxSession(); viewSidRef.current = ""; setCreateView(null); setSkillCenter(false); @@ -1555,6 +1808,7 @@ export default function App() { setError("当前账号没有管理 Agent 的权限。"); return; } + if (sandboxSession) exitSandboxSession(); viewSidRef.current = ""; setSessionId(""); setCreateView(null); @@ -1582,35 +1836,153 @@ export default function App() { {(() => { const composer = ( - { - const text = input; - const atts = attachments; - const selectedInvocation = invocation; - setInput(""); - setAttachments([]); - setInvocation(emptyInvocation()); - send(text, atts, selectedInvocation); - releaseAttachmentPreviews(atts); - }} - disabled={!appName || !userId} - busy={conversationBusy} - showMeta={turns.length > 0} - attachments={attachments} - skills={availableSkills} - agents={availableAgents} - invocation={invocation} - capabilitiesLoading={capabilitiesLoading} - onInvocationChange={setInvocation} - onAddFiles={addFiles} - onRemoveAttachment={removeDraftAttachment} - /> +
+ {sandboxSession && ( + + )} + { + if (!sandboxSession && newChatMode === "skill-create") { + const prompt = input.trim(); + if (!prompt || skillCreating) return; + const provisionalJob: SkillCreationJob = { + id: `pending-${Date.now()}`, + prompt, + status: "provisioning", + candidates: SKILL_MODELS.map((model, index) => ({ + id: `pending-${index}`, + model, + modelLabel: model, + status: "queued", + stage: "provisioning", + files: [], + activities: [{ + id: "provisioning", + kind: "status", + text: "正在拉起 Sandbox", + status: "running", + }], + })), + }; + setSkillCreating(true); + const creationRun = ++skillCreationRunRef.current; + setError(""); + setSkillJob(provisionalJob); + setInput(""); + void createSkillJob(prompt, (job) => { + if (skillCreationRunRef.current === creationRun) { + setSkillJob(job); + } + }) + .then((job) => { + if (skillCreationRunRef.current === creationRun) { + setSkillJob(job); + } + }) + .catch((cause) => { + if (skillCreationRunRef.current === creationRun) { + setSkillJob(null); + setInput(prompt); + setError(cause instanceof Error ? cause.message : String(cause)); + } + }) + .finally(() => { + if (skillCreationRunRef.current === creationRun) { + setSkillCreating(false); + } + }); + return; + } + const text = input; + setInput(""); + if (sandboxSession) { + void sendSandboxMessage(text); + return; + } + const atts = attachments; + const selectedInvocation = invocation; + setAttachments([]); + setInvocation(emptyInvocation()); + send(text, atts, selectedInvocation); + releaseAttachmentPreviews(atts); + }} + disabled={ + sandboxSession + ? false + : !userId || + newChatMode === "temporary" || + (newChatMode === "agent" && !appName) + } + busy={ + sandboxSession + ? sandboxBusy + : newChatMode === "skill-create" + ? skillCreating + : conversationBusy + } + showMeta={turns.length > 0 && !sandboxSession} + attachments={sandboxSession ? [] : attachments} + skills={sandboxSession ? [] : availableSkills} + agents={sandboxSession ? [] : availableAgents} + invocation={sandboxSession ? emptyInvocation() : invocation} + capabilitiesLoading={!sandboxSession && capabilitiesLoading} + allowAttachments={!sandboxSession} + onInvocationChange={setInvocation} + onAddFiles={addFiles} + onRemoveAttachment={removeDraftAttachment} + newChatMode={sandboxSession ? "agent" : newChatMode} + newChatLayout={!sandboxSession && turns.length === 0 && skillJob === null} + showModeSelector={ + !sandboxSession && + turns.length === 0 && + skillJob === null && + canCreateAgents + } + temporaryEnabled={newChatCapabilities.temporaryEnabled} + skillCreateEnabled={newChatCapabilities.skillCreateEnabled} + onModeChange={(mode) => { + if ( + (mode === "temporary" && !newChatCapabilities.temporaryEnabled) || + (mode === "skill-create" && !newChatCapabilities.skillCreateEnabled) + ) return; + if (mode === "temporary") { + setNewChatMode(mode); + openSandboxLaunch(); + return; + } + setNewChatMode(mode); + setError(""); + if (mode === "skill-create") { + setInvocation(emptyInvocation()); + const abandonedSession = + sessionId && persistentTurns.length === 0 && attachments.length > 0 + ? sessionId + : ""; + discardDraftAttachments(attachments); + setAttachments([]); + if (abandonedSession) { + viewSidRef.current = ""; + setSessionId(""); + void abandonDraftSession(abandonedSession); + } + } + }} + /> +
); return (
@@ -1625,7 +1997,7 @@ export default function App() { : showAddAgent ? "添加 AgentKit 智能体" : skillCenter - ? "技能中心" + ? undefined : searchView ? "搜索" : showManageAgents @@ -1635,7 +2007,9 @@ export default function App() { : conversationTitle } crumbs={ - searchView || showAddAgent || skillCenter || showAddMenu || !visibleCreateView + skillCenter + ? [{ label: "技能中心" }, { label: "AgentKit Skill 空间" }] + : searchView || showAddAgent || showAddMenu || !visibleCreateView ? undefined : visibleCreateView === "menu" ? [ @@ -1661,7 +2035,7 @@ export default function App() { /> } /> -
+
{error &&
{error}
} {loadingSession && (
@@ -1768,22 +2142,30 @@ export default function App() { setCreateView("menu")} onCreate={onCreate} /> ) : visibleCreateView === "workflow" ? ( setCreateView("menu")} onCreate={onCreate} /> + ) : turns.length === 0 && skillJob ? ( + ) : turns.length === 0 ? ( <>
- {greeting} + {sandboxSession + ? "让灵感在临时空间里自由生长" + : newChatMode === "skill-create" + ? "想创建一个什么 Skill?" + : greeting} {composer}
{/* Show the agent's structure as soon as it's selected, before any conversation — only renders when it has sub-agents. */} - + {!sandboxSession && newChatMode === "agent" ? ( + + ) : null} ) : ( <> @@ -1832,28 +2214,30 @@ export default function App() { transition={{ duration: 0.2, ease: "easeOut" }} > {pending ? ( - isLast && conversationBusy ? : null + isLast && activeConversationBusy ? : null ) : ( <> {/* Finalized turn that produced no visible answer (e.g. only thinking + an empty A2UI surface) — show a fallback note. */} - {!(isLast && conversationBusy) && !turnHasVisibleContent(turn) && ( + {!(isLast && activeConversationBusy) && !turnHasVisibleContent(turn) && (
本次没有返回可显示的内容。
)} {/* Hide the actions/timestamp row while this turn is still thinking/streaming or waiting on an OAuth card; reveal it only once the reply is done. */} - {!(isLast && conversationBusy) && !turnAwaitingAuth(turn) && ( + {!(isLast && activeConversationBusy) && !turnAwaitingAuth(turn) && (
- + {!sandboxSession && ( + + )}
{turn.meta && {fmtMeta(turn.meta)}} @@ -1865,12 +2249,14 @@ export default function App() { ); })}
- + {!sandboxSession && ( + + )} {composer} )} @@ -1887,6 +2273,14 @@ export default function App() { /> )} + void launchSandboxSession()} + /> + {confirmLeave && (
setConfirmLeave(false)}>
e.stopPropagation()}> diff --git a/frontend/src/adk/identity.ts b/frontend/src/adk/identity.ts index 81b60bb7..479d5faa 100644 --- a/frontend/src/adk/identity.ts +++ b/frontend/src/adk/identity.ts @@ -10,6 +10,7 @@ import { BOOT_REQUEST_TIMEOUT_MS, requestSignal } from "./timeout"; const LOCAL_USER_KEY = "veadk_local_user"; +const TAB_LOCAL_USER_KEY = "veadk_local_user_tab"; export type AuthStatus = "authenticated" | "unauthenticated"; @@ -26,13 +27,26 @@ export const USERNAME_RE = /^[A-Za-z0-9]{1,16}$/; export function getLocalUser(): string | null { try { - return localStorage.getItem(LOCAL_USER_KEY); + const tabUser = sessionStorage.getItem(TAB_LOCAL_USER_KEY); + if (tabUser) return tabUser; + const savedUser = localStorage.getItem(LOCAL_USER_KEY); + if (savedUser) sessionStorage.setItem(TAB_LOCAL_USER_KEY, savedUser); + return savedUser; } catch { - return null; + try { + return localStorage.getItem(LOCAL_USER_KEY); + } catch { + return null; + } } } export function setLocalUser(name: string): void { + try { + sessionStorage.setItem(TAB_LOCAL_USER_KEY, name); + } catch { + /* ignore */ + } try { localStorage.setItem(LOCAL_USER_KEY, name); } catch { @@ -41,6 +55,11 @@ export function setLocalUser(name: string): void { } export function clearLocalUser(): void { + try { + sessionStorage.removeItem(TAB_LOCAL_USER_KEY); + } catch { + /* ignore */ + } try { localStorage.removeItem(LOCAL_USER_KEY); } catch { diff --git a/frontend/src/adk/newChatCapabilities.ts b/frontend/src/adk/newChatCapabilities.ts new file mode 100644 index 00000000..f7ca9e68 --- /dev/null +++ b/frontend/src/adk/newChatCapabilities.ts @@ -0,0 +1,36 @@ +import { withAuth } from "./auth"; +import { withLocalUser } from "./identity"; +import { requestSignal } from "./timeout"; + +const CAPABILITY_TIMEOUT_MS = 10_000; + +export interface NewChatModeCapability { + enabled: boolean; + reason?: string; +} + +async function getCapability(path: string): Promise { + const response = await fetch(withAuth(path), { + headers: withLocalUser({ Accept: "application/json" }), + signal: requestSignal(undefined, CAPABILITY_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`读取会话模式能力失败(HTTP ${response.status})`); + } + const payload = await response.json() as Record; + if (typeof payload.enabled !== "boolean") { + throw new Error("会话模式能力响应格式错误"); + } + return { + enabled: payload.enabled, + reason: typeof payload.reason === "string" ? payload.reason : undefined, + }; +} + +export async function getSandboxCapability(): Promise { + return getCapability("/web/sandbox/capabilities"); +} + +export async function getSkillCreatorCapability(): Promise { + return getCapability("/web/skill-creator/capabilities"); +} diff --git a/frontend/src/adk/sandbox.ts b/frontend/src/adk/sandbox.ts new file mode 100644 index 00000000..68981af6 --- /dev/null +++ b/frontend/src/adk/sandbox.ts @@ -0,0 +1,243 @@ +import { withAuth } from "./auth"; +import { withLocalUser } from "./identity"; +import { requestSignal } from "./timeout"; +import type { Block } from "../blocks"; + +const SANDBOX_API = "/web/sandbox/sessions"; +const START_TIMEOUT_MS = 330_000; +const MESSAGE_TIMEOUT_MS = 600_000; +const CLOSE_TIMEOUT_MS = 15_000; + +export interface SandboxRequestOptions { + signal?: AbortSignal; + onBlocks?: (blocks: Block[]) => void; +} + +export interface SandboxSession { + id: string; + toolName: "codex"; + createdAt: string; +} + +export interface SandboxMessage { + sessionId: string; + text: string; +} + +export interface SandboxReply { + text: string; + blocks: Block[]; +} + +export interface AgentKitSandboxClient { + startSession(options?: SandboxRequestOptions): Promise; + sendMessage( + message: SandboxMessage, + options?: SandboxRequestOptions, + ): Promise; + closeSession( + sessionId: string, + options?: SandboxRequestOptions, + ): Promise; +} + +interface CreateSessionResponse { + sessionId: string; + status: string; +} + +interface SandboxErrorPayload { + detail?: unknown; + message?: unknown; +} + +interface SandboxStreamPayload { + id?: unknown; + kind?: unknown; + status?: unknown; + text?: unknown; + name?: unknown; + args?: unknown; + response?: unknown; + message?: unknown; +} + +function sandboxHeaders(headers?: HeadersInit): Headers { + const next = withLocalUser(headers); + next.set("Accept", "application/json"); + return next; +} + +async function responseError(response: Response, fallback: string): Promise { + let payload: SandboxErrorPayload = {}; + try { + payload = (await response.json()) as SandboxErrorPayload; + } catch { + return new Error(`${fallback}(HTTP ${response.status})`); + } + const nestedDetail = payload.detail; + const detail = + nestedDetail && typeof nestedDetail === "object" && "message" in nestedDetail + ? (nestedDetail as SandboxErrorPayload).message + : (nestedDetail ?? payload.message); + return new Error(typeof detail === "string" && detail ? detail : fallback); +} + +async function parseSandboxStream( + response: Response, + onBlocks?: (blocks: Block[]) => void, +): Promise { + if (!response.body) throw new Error("沙箱对话服务未返回内容。"); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let reply = ""; + const blocks: Block[] = []; + const activityIndexes = new Map(); + + function emitBlocks(): void { + onBlocks?.(blocks.map((block) => ({ ...block }))); + } + + function appendReply(text: string): void { + reply += text; + const last = blocks[blocks.length - 1]; + if (last?.kind === "text") last.text += text; + else blocks.push({ kind: "text", text }); + emitBlocks(); + } + + function applyActivity(payload: SandboxStreamPayload): void { + if ( + typeof payload.id !== "string" || + (payload.kind !== "thinking" && payload.kind !== "tool") || + (payload.status !== "running" && payload.status !== "done") + ) return; + const done = payload.status === "done"; + let block: Block; + if (payload.kind === "thinking") { + if (typeof payload.text !== "string" || !payload.text) return; + block = { kind: "thinking", text: payload.text, done }; + } else { + if (typeof payload.name !== "string" || !payload.name) return; + block = { + kind: "tool", + name: payload.name, + args: payload.args, + response: payload.response, + done, + }; + } + const existing = activityIndexes.get(payload.id); + if (existing === undefined) { + activityIndexes.set(payload.id, blocks.length); + blocks.push(block); + } else { + blocks[existing] = block; + } + emitBlocks(); + } + + function consumeFrame(frame: string): void { + let event = "message"; + const data: string[] = []; + for (const line of frame.split(/\r?\n/)) { + if (line.startsWith("event:")) event = line.slice(6).trim(); + if (line.startsWith("data:")) data.push(line.slice(5).trimStart()); + } + if (data.length === 0) return; + + let payload: SandboxStreamPayload; + try { + payload = JSON.parse(data.join("\n")) as SandboxStreamPayload; + } catch { + throw new Error("沙箱对话服务返回了无法解析的响应。"); + } + if (event === "error") { + throw new Error( + typeof payload.message === "string" && payload.message + ? payload.message + : "沙箱对话失败,请稍后重试。", + ); + } + if (event === "activity") applyActivity(payload); + if (event === "delta" && typeof payload.text === "string") { + appendReply(payload.text); + } + if (event === "done" && !reply && typeof payload.text === "string") { + appendReply(payload.text); + } + } + + while (true) { + const { done, value } = await reader.read(); + buffer += decoder.decode(value, { stream: !done }); + const frames = buffer.split(/\r?\n\r?\n/); + buffer = frames.pop() ?? ""; + frames.forEach(consumeFrame); + if (done) break; + } + if (buffer.trim()) consumeFrame(buffer); + if (blocks.length === 0) throw new Error("沙箱未返回有效回复,请重试。"); + return { text: reply, blocks }; +} + +export const sandboxClient: AgentKitSandboxClient = { + async startSession(options = {}) { + const response = await fetch(withAuth(SANDBOX_API), { + method: "POST", + headers: sandboxHeaders({ "Content-Type": "application/json" }), + signal: requestSignal(options.signal, START_TIMEOUT_MS), + }); + if (!response.ok) { + throw await responseError(response, "无法启动 AgentKit 沙箱,请稍后重试。"); + } + const data = (await response.json()) as CreateSessionResponse; + if (!data.sessionId || data.status !== "ready") { + throw new Error("AgentKit 沙箱返回了无效的会话信息。"); + } + return { + id: data.sessionId, + toolName: "codex", + createdAt: new Date().toISOString(), + }; + }, + + async sendMessage(message, options = {}) { + if (!message.sessionId || !message.text.trim()) { + throw new Error("临时会话缺少有效的消息内容。"); + } + const response = await fetch( + withAuth(`${SANDBOX_API}/${encodeURIComponent(message.sessionId)}/messages`), + { + method: "POST", + headers: sandboxHeaders({ + Accept: "text/event-stream", + "Content-Type": "application/json", + }), + body: JSON.stringify({ message: message.text }), + signal: requestSignal(options.signal, MESSAGE_TIMEOUT_MS), + }, + ); + if (!response.ok) { + throw await responseError(response, "沙箱对话失败,请稍后重试。"); + } + return parseSandboxStream(response, options.onBlocks); + }, + + async closeSession(sessionId, options = {}) { + if (!sessionId) return; + const response = await fetch( + withAuth(`${SANDBOX_API}/${encodeURIComponent(sessionId)}`), + { + method: "DELETE", + headers: sandboxHeaders(), + signal: requestSignal(options.signal, CLOSE_TIMEOUT_MS), + }, + ); + if (!response.ok && response.status !== 404) { + throw await responseError(response, "无法清理 AgentKit 沙箱会话。"); + } + }, +}; diff --git a/frontend/src/create/skills/skillspace.ts b/frontend/src/create/skills/skillspace.ts index 631fd18f..2fad7dff 100644 --- a/frontend/src/create/skills/skillspace.ts +++ b/frontend/src/create/skills/skillspace.ts @@ -12,6 +12,9 @@ export interface SkillSpaceRef { description: string; status: string; region?: string; + projectName?: string; + updatedAt?: string; + skillCount?: number; } export interface SkillSpaceSkill { skillId: string; @@ -31,6 +34,20 @@ export interface SkillDetail { tosPath: string; } +export interface SkillSpacePage { + items: T[]; + totalCount: number; + page: number; + pageSize: number; +} + +export interface SkillSpacePageOptions { + region: string; + page: number; + pageSize: number; + project?: string; +} + async function jfetch(url: string): Promise { const res = await fetch(url, { headers: { accept: "application/json" }, @@ -63,6 +80,18 @@ export async function listSkillSpaces(): Promise { return data.items || []; } +export async function listSkillSpacesPage( + options: SkillSpacePageOptions, +): Promise> { + const params = new URLSearchParams({ + region: options.region, + page: String(options.page), + page_size: String(options.pageSize), + }); + if (options.project) params.set("project", options.project); + return jfetch>(`/web/skill-spaces?${params.toString()}`); +} + export async function listSkillsInSpace(spaceId: string, region?: string): Promise { const q = region ? `?region=${encodeURIComponent(region)}` : ""; const data = await jfetch<{ items: SkillSpaceSkill[] }>( @@ -71,15 +100,32 @@ export async function listSkillsInSpace(spaceId: string, region?: string): Promi return data.items || []; } +export async function listSkillsInSpacePage( + spaceId: string, + options: SkillSpacePageOptions, +): Promise> { + const params = new URLSearchParams({ + region: options.region, + page: String(options.page), + page_size: String(options.pageSize), + }); + if (options.project) params.set("project", options.project); + return jfetch>( + `/web/skill-spaces/${encodeURIComponent(spaceId)}/skills?${params.toString()}`, + ); +} + export async function getSkillDetail( spaceId: string, skillId: string, version?: string, region?: string, + project?: string, ): Promise { const params: string[] = []; if (version) params.push(`version=${encodeURIComponent(version)}`); if (region) params.push(`region=${encodeURIComponent(region)}`); + if (project) params.push(`project=${encodeURIComponent(project)}`); const q = params.length > 0 ? `?${params.join("&")}` : ""; return jfetch( `/web/skill-spaces/${encodeURIComponent(spaceId)}/skills/${encodeURIComponent(skillId)}${q}`, diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 238a1484..33fa2d21 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -131,6 +131,13 @@ body { text-overflow: ellipsis; white-space: nowrap; } +.brand-logo, +.brand-title, +.login-brand-logo, +.login-brand, +.login-title { + cursor: text; +} .sidebar-collapse-toggle { flex: 0 0 28px; width: 28px; @@ -656,7 +663,7 @@ body { box-shadow: 0 1px 2px hsl(var(--foreground) / 0.07); } .agentsel-mine { display: flex; align-items: center; gap: 7px; font-size: 12.5px; color: hsl(var(--muted-foreground)); cursor: pointer; } -.agentsel-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0; } +.agentsel-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; } /* Stable-height area for the paged list: old items stay put while the next * page loads, and a centered overlay indicates progress (no height jump). */ .agentsel-listwrap { position: relative; min-height: 220px; } @@ -1451,7 +1458,12 @@ body { min-height: 32px; padding: 3px 7px 3px 3px; } -.tool-name { font-weight: 400; color: inherit; } +.tool-name { + color: inherit; + font-size: 13.5px; + font-weight: 400; + line-height: 1.35; +} .tool-icon { width: 20px; height: 26px; @@ -1815,6 +1827,47 @@ body { border-radius: 26px; background: hsl(var(--background)); } +.composer--new-chat .composer-box { + display: block; + min-height: 136px; + padding: 10px; + border-radius: 16px; +} +.composer-input-stack { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; +} +.composer-input-stack .comp-input { width: 100%; } +.composer--new-chat .composer-input-stack { min-height: 114px; } +.composer--new-chat .comp-input { + min-height: 76px; + padding: 4px 10px; +} +.composer--new-chat .composer-menu-wrap { + position: absolute; + bottom: 10px; + left: 10px; + height: 36px; +} +.composer--new-chat .new-chat-mode { + position: absolute; + right: 52px; + bottom: 10px; + display: flex; + align-items: center; + min-height: 36px; +} +.composer--new-chat .comp-send { + position: absolute; + right: 10px; + bottom: 10px; +} +.composer--new-chat .comp-send .icon { + width: 20px; + height: 20px; +} .composer-meta { display: flex; align-items: center; @@ -3341,12 +3394,413 @@ body { right: 10px; } -/* ---------- skill center (external iframe filling the main panel) ---------- */ -.skill-frame { +/* ---------- skill center ---------- */ +.skillcenter { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + padding: 0; +} +.skillcenter-regions { + display: grid; + padding: 2px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--canvas) / 0.62); +} +.skillcenter-regions button { + border: 0; + background: transparent; + color: hsl(var(--muted-foreground)); + font: inherit; + cursor: pointer; +} +.skillcenter-regions button.active { + background: hsl(var(--background)); + color: hsl(var(--foreground)); + box-shadow: 0 1px 2px hsl(var(--foreground) / 0.07); +} +.skillcenter-regions button:focus-visible, +.skillcenter-pager button:focus-visible, +.skill-detail-close:focus-visible { + outline: 2px solid hsl(var(--ring) / 0.28); + outline-offset: 1px; +} +.skillcenter-space-item:focus-visible, +.skillcenter-skill-item:focus-visible { + outline: none; + border-color: transparent; + background: hsl(var(--muted) / 0.62); +} +.skillcenter-regions { + grid-template-columns: repeat(2, 58px); + border-radius: 7px; +} +.skillcenter-regions button { + height: 27px; + border-radius: 5px; + font-size: 11.5px; +} +.skillcenter-browser { flex: 1; + min-width: 0; + min-height: 0; + display: grid; + grid-template-columns: minmax(270px, 0.9fr) minmax(360px, 1.35fr); + gap: 0; +} +.skillcenter-panel { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.skillcenter-panel + .skillcenter-panel { + border-left: 1px solid hsl(var(--border)); +} +.skillcenter-panel-head { + height: 48px; + flex: 0 0 48px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 14px; + border-bottom: 1px solid hsl(var(--border)); +} +.skillcenter-panel-head > div { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; +} +.skillcenter-panel-head .icon { + width: 17px; + height: 17px; + color: hsl(var(--muted-foreground)); +} +.skillcenter-panel-head h2 { + min-width: 0; + margin: 0; + overflow: hidden; + color: hsl(var(--foreground)); + font-size: 13.5px; + font-weight: 620; + text-overflow: ellipsis; + white-space: nowrap; +} +.skillcenter-panel-head > span { + flex-shrink: 0; + color: hsl(var(--muted-foreground)); + font-size: 11.5px; +} +.skillcenter-count-badge { + min-width: 25px; + height: 21px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 7px; + border-radius: 999px; + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); + font-size: 11px; + font-weight: 600; + line-height: 1; +} +.skillcenter-listwrap { + position: relative; + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} +.skillcenter-list { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px; +} +.skillcenter-space-item, +.skillcenter-skill-item { width: 100%; - border: none; - background: hsl(var(--panel)); + min-width: 0; + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: hsl(var(--foreground)); + font: inherit; + text-align: left; + cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease; +} +.skillcenter-space-item:hover, +.skillcenter-skill-item:hover { + border-color: transparent; + background: hsl(var(--muted) / 0.62); +} +.skillcenter-space-item.active { + border-color: transparent; + background: hsl(var(--muted) / 0.62); +} +.skillcenter-symbol { + width: 30px; + height: 30px; + flex: 0 0 30px; + display: grid; + place-items: center; + border: 1px solid hsl(var(--border)); + border-radius: 7px; + background: hsl(var(--background)); + color: hsl(var(--foreground) / 0.78); +} +.skillcenter-symbol .icon { width: 18px; height: 18px; } +.skillcenter-symbol--skill { + color: hsl(214 64% 43%); + background: hsl(214 75% 97%); + border-color: hsl(214 54% 87%); +} +.skillcenter-item-body { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} +.skillcenter-item-title { + min-width: 0; + overflow: hidden; + font-size: 13px; + font-weight: 600; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} +.skillcenter-item-description { + display: -webkit-box; + min-width: 0; + overflow: hidden; + color: hsl(var(--muted-foreground)); + font-size: 12px; + line-height: 17px; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} +.skillcenter-item-meta { + min-width: 0; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px 8px; + margin-top: 2px; +} +.skillcenter-status { + flex-shrink: 0; + padding: 2px 6px; + border-radius: 999px; + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); + font-size: 10.5px; + line-height: 16px; +} +.skillcenter-status.is-positive { color: hsl(145 61% 29%); background: hsl(145 55% 94%); } +.skillcenter-status.is-progress { color: hsl(35 78% 31%); background: hsl(42 86% 94%); } +.skillcenter-status.is-danger { color: hsl(var(--destructive)); background: hsl(var(--destructive) / 0.09); } +.skillcenter-meta-text { + min-width: 0; + max-width: 170px; + overflow: hidden; + color: hsl(var(--muted-foreground)); + font-size: 10.5px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} +.skillcenter-pager { + height: 44px; + flex: 0 0 44px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 12px; + border-top: 1px solid hsl(var(--border)); + color: hsl(var(--muted-foreground)); + font-size: 11.5px; +} +.skillcenter-pager-actions { display: flex; align-items: center; gap: 7px; } +.skillcenter-pager-actions > span { min-width: 38px; text-align: center; } +.skillcenter-pager button, +.skill-detail-close { + display: grid; + place-items: center; + border: 0; + border-radius: 6px; + background: transparent; + color: hsl(var(--muted-foreground)); + cursor: pointer; +} +.skillcenter-pager button { width: 26px; height: 26px; padding: 0; } +.skillcenter-pager button:hover:not(:disabled), +.skill-detail-close:hover { color: hsl(var(--foreground)); background: hsl(var(--accent)); } +.skillcenter-pager button:disabled { opacity: 0.35; cursor: default; } +.skillcenter-pager button .icon { width: 17px; height: 17px; } +.skillcenter-empty, +.skillcenter-loading, +.skillcenter-error { + min-height: 130px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 24px; + color: hsl(var(--muted-foreground)); + font-size: 12.5px; + line-height: 1.55; + text-align: center; + overflow-wrap: anywhere; +} +.skillcenter-error { color: hsl(var(--destructive)); } +.skillcenter-loading--overlay { + position: absolute; + inset: 0; + z-index: 2; + min-height: 0; + background: hsl(var(--background) / 0.82); + backdrop-filter: blur(1px); +} +.skillcenter-loading-mark { + width: 14px; + height: 14px; + flex-shrink: 0; + border: 1.5px solid hsl(var(--foreground) / 0.16); + border-top-color: hsl(var(--foreground) / 0.62); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +.skill-detail-backdrop { + position: fixed; + inset: 0; + z-index: 80; + display: grid; + place-items: center; + padding: 16px; + background: hsl(var(--foreground) / 0.25); + backdrop-filter: blur(3px); +} +.skill-detail-dialog { + width: min(760px, 100%); + height: min(760px, 100%); + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid hsl(var(--border)); + border-radius: 12px; + background: hsl(var(--background)); + box-shadow: 0 18px 48px hsl(var(--foreground) / 0.16); +} +.skill-detail-head { + flex-shrink: 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 16px 18px 14px; + border-bottom: 1px solid hsl(var(--border)); +} +.skill-detail-heading { min-width: 0; display: flex; align-items: flex-start; gap: 11px; } +.skill-detail-heading > div { min-width: 0; } +.skill-detail-heading h2 { + margin: 1px 0 4px; + overflow: hidden; + font-size: 16px; + font-weight: 650; + line-height: 22px; + text-overflow: ellipsis; + white-space: nowrap; +} +.skill-detail-heading p { + display: -webkit-box; + margin: 0; + overflow: hidden; + color: hsl(var(--muted-foreground)); + font-size: 12px; + line-height: 17px; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} +.skill-detail-close { width: 30px; height: 30px; flex: 0 0 30px; padding: 0; } +.skill-detail-meta { + flex-shrink: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px 18px; + margin: 0; + padding: 14px 18px; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--canvas) / 0.45); +} +.skill-detail-meta > div { min-width: 0; } +.skill-detail-meta dt { margin-bottom: 3px; color: hsl(var(--muted-foreground)); font-size: 10.5px; } +.skill-detail-meta dd { + margin: 0; + overflow: hidden; + color: hsl(var(--foreground)); + font-size: 12px; + line-height: 17px; + text-overflow: ellipsis; + white-space: nowrap; +} +.skill-detail-content { flex: 1; min-height: 0; display: flex; flex-direction: column; } +.skill-detail-content-title { + height: 40px; + flex: 0 0 40px; + display: flex; + align-items: center; + padding: 0 18px; + border-bottom: 1px solid hsl(var(--border)); + font-size: 12px; + font-weight: 600; +} +.skill-detail-content > .skillcenter-loading, +.skill-detail-content > .skillcenter-error, +.skill-detail-content > .skillcenter-empty { flex: 1; min-height: 0; } +.skill-detail-markdown { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 18px 22px 28px; + overflow-wrap: anywhere; +} + +@media (max-width: 760px) { + .skillcenter-browser { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(0, 1fr)); + } + .skillcenter-panel + .skillcenter-panel { + border-top: 1px solid hsl(var(--border)); + border-left: 0; + } + .skill-detail-meta { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 560px) { + .skillcenter-regions { grid-template-columns: repeat(2, 46px); } + .skillcenter-browser { gap: 8px; } + .skillcenter-item-meta { gap: 4px 6px; } + .skillcenter-meta-text { max-width: 132px; } + .skill-detail-backdrop { padding: 8px; } + .skill-detail-dialog { border-radius: 10px; } + .skill-detail-meta { grid-template-columns: minmax(0, 1fr); gap: 8px; max-height: 180px; overflow-y: auto; } } /* ---------- add AgentKit agent (URL + key form in the main panel) ---------- */ @@ -3724,8 +4178,16 @@ a.search-result { text-decoration: none; color: inherit; } .login-btn:hover { background: hsl(var(--accent)); border-color: hsl(var(--ring) / 0.3); } .login-btn:active { transform: scale(0.99); } .login-btn .icon { width: 18px; height: 18px; } -.login-legal { margin: 18px 0 0; font-size: 12px; color: hsl(var(--muted-foreground)); } -.login-powered { margin: 6px 0 0; font-size: 12px; color: hsl(var(--muted-foreground)); } +.login-powered { margin: 18px 0 0; font-size: 12px; color: hsl(var(--muted-foreground)); } +.login-legal { margin: 6px 0 0; font-size: 12px; color: hsl(var(--muted-foreground)); } +.login-legal a { + color: inherit; + font-weight: 600; + text-decoration: underline; + text-decoration-color: hsl(var(--muted-foreground) / 0.45); + text-underline-offset: 2px; +} +.login-legal a:hover { color: hsl(var(--foreground)); } .login-footer { padding: 18px 24px; text-align: center; font-size: 12px; color: hsl(var(--muted-foreground)); } /* ---------- reduced motion ---------- */ diff --git a/frontend/src/ui/Composer.tsx b/frontend/src/ui/Composer.tsx index 7dce588a..3b0c776e 100644 --- a/frontend/src/ui/Composer.tsx +++ b/frontend/src/ui/Composer.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { ArrowUp, AtSign, @@ -22,6 +22,9 @@ import type { import { InvocationChips } from "./InvocationChips"; import { MediaGroup } from "./Media"; import { isImeCompositionEvent } from "./composerKeyboard"; +import { NewChatModeSelector } from "./new-chat-modes/NewChatModeSelector"; +import type { NewChatMode } from "./new-chat-modes/types"; +import { SKILL_MODELS } from "./skill-create/types"; interface CompletionTrigger { kind: "skill" | "agent"; @@ -50,9 +53,16 @@ export interface ComposerProps { agents: AgentTarget[]; invocation: FrontendInvocation; capabilitiesLoading?: boolean; + allowAttachments?: boolean; onInvocationChange: (value: FrontendInvocation) => void; onAddFiles: (files: FileList | File[]) => void; onRemoveAttachment: (id: string) => void; + newChatMode?: NewChatMode; + newChatLayout?: boolean; + showModeSelector?: boolean; + onModeChange?: (value: NewChatMode) => void; + temporaryEnabled?: boolean; + skillCreateEnabled?: boolean; } export function Composer({ @@ -71,9 +81,16 @@ export function Composer({ agents, invocation, capabilitiesLoading = false, + allowAttachments = true, onInvocationChange, onAddFiles, onRemoveAttachment, + newChatMode = "agent", + newChatLayout = false, + showModeSelector = false, + onModeChange, + temporaryEnabled, + skillCreateEnabled, }: ComposerProps) { const ref = useRef(null); const imageInput = useRef(null); @@ -103,9 +120,15 @@ export function Composer({ el.style.height = `${Math.min(el.scrollHeight, 200)}px`; }, [value]); - const uploadPending = attachments.some((attachment) => attachment.status !== "ready"); + const skillMode = newChatMode === "skill-create"; + useEffect(() => { + if (!skillMode) return; + setMenuOpen(false); + setTrigger(null); + }, [skillMode]); + const uploadPending = !skillMode && attachments.some((attachment) => attachment.status !== "ready"); const canSend = !disabled && !busy && !uploadPending && - (value.trim().length > 0 || attachments.length > 0); + (value.trim().length > 0 || (!skillMode && attachments.length > 0)); const query = trigger?.query.toLocaleLowerCase() ?? ""; const suggestions: CompletionItem[] = trigger?.kind === "skill" @@ -186,16 +209,18 @@ export function Composer({ } return ( -
- onInvocationChange({ - ...invocation, - skills: invocation.skills.filter((skill) => skill.name !== name), - })} - onRemoveAgent={() => onInvocationChange({ skills: [] })} - /> - {attachments.length > 0 && ( +
+ {!skillMode ? ( + onInvocationChange({ + ...invocation, + skills: invocation.skills.filter((skill) => skill.name !== name), + })} + onRemoveAgent={() => onInvocationChange({ skills: [] })} + /> + ) : null} + {!skillMode && attachments.length > 0 && ( ) : null} -
+ {!skillMode ?
)} -
+
: null} -