diff --git a/docs/content/docs/framework/frontend/web-ui.en.mdx b/docs/content/docs/framework/frontend/web-ui.en.mdx index dba2b035..4209d4c4 100644 --- a/docs/content/docs/framework/frontend/web-ui.en.mdx +++ b/docs/content/docs/framework/frontend/web-ui.en.mdx @@ -5,6 +5,16 @@ description: "A bundled React UI that renders agent-streamed A2UI, served alongs VeADK ships a React frontend that renders [A2UI](/en/docs/framework/frontend/a2ui) (agent-driven UI) streamed from an agent over the Google ADK API server. It talks to the same server process that `veadk frontend` launches, so there is **no separate backend to deploy**. +## What it can do + +- **Chat**: multi-turn conversations that render streamed A2UI cards, plus thinking, tool calls, token usage and timing. +- **Agent picker**: switch agents from the top-left; hover an agent to see its model and mounted tools. +- **Session history**: auto-saved, time-sorted, reopen or delete. +- **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. +- **Tracing**: view the call flame graph for the current session. +- **Login**: SSO (VeIdentity / GitHub / Google / any OIDC) or a local username. + ## Run Build the UI once, then serve the UI and the agent API together with a single command: @@ -39,7 +49,8 @@ cd frontend && npm run dev # http://localhost:5173 (proxies A | `--oauth2-user-pool` / `--oauth2-user-pool-client` | — | VeIdentity user pool / client **name**. Enables SSO when set. | | `--oauth2-user-pool-uid` / `--oauth2-user-pool-client-uid` | env `OAUTH2_USER_POOL_ID` / `OAUTH2_USER_POOL_CLIENT_ID` | Specify the pool / client by **UID** instead of name. | | `--oauth2-provider` / `--oauth2-provider-label` | `veidentity` | Login button provider id and display label. | -| `--oauth2-redirect-uri` | `http://{host}:{port}/oauth2/callback` | OAuth2 callback URL. | +| `--oauth2-redirect-uri` | `http://{host}:{port}/oauth2/callback` | OAuth2 callback URL (env `OAUTH2_REDIRECT_URI`). Set this when deploying behind a public host / runtime. | +| Third-party / custom provider | see below | Connect GitHub / Google / any OIDC via env vars; see the Authentication section. | Outside dev mode, if the built frontend directory is not found, the command fails and asks you to run `npm run build` first (or use `--dev` with the Vite dev server). @@ -66,6 +77,43 @@ protects the API while exempting the SPA shell and `GET /web/auth-config`, so th app loads and shows its own login page instead of being bounced to the IdP. The login button's label/icon is config-driven (`--oauth2-provider` / `-label`). +**Third-party / custom OAuth2 (env vars)** — without a VeIdentity user pool, set +`OAUTH2_CLIENT_ID` (and secret) to enable GitHub, Google, or any OAuth2/OIDC +login. Endpoints are resolved in this order: a built-in preset +(`OAUTH2_PROVIDER=github` / `google`) → OIDC discovery (set `OAUTH2_ISSUER`) → +explicit endpoints (`OAUTH2_AUTHORIZE_URL`, etc.). + +| Env var | Description | +| :-- | :-- | +| `OAUTH2_PROVIDER` | Provider id: `github`, `google`, or a custom name. Drives the login button label and the preset. | +| `OAUTH2_CLIENT_ID` / `OAUTH2_CLIENT_SECRET` | OAuth2 client credentials. **Setting `OAUTH2_CLIENT_ID` enables the generic provider.** | +| `OAUTH2_ISSUER` | OIDC issuer base URL; endpoints are auto-discovered (e.g. `https://accounts.google.com`). | +| `OAUTH2_AUTHORIZE_URL` / `OAUTH2_TOKEN_URL` / `OAUTH2_USERINFO_URL` | Explicit endpoints (for non-OIDC providers). | +| `OAUTH2_SCOPE` | Override the requested scopes. | +| `OAUTH2_PROVIDER_LABEL` | Override the login button text. | +| `OAUTH2_REDIRECT_URI` | **Callback URL.** Set this when deploying behind a public host / runtime, and register the same value in your OAuth app; defaults to `http://{host}:{port}/oauth2/callback` locally. | + +GitHub (preset — only id/secret needed): + +```bash +export OAUTH2_PROVIDER=github +export OAUTH2_CLIENT_ID= +export OAUTH2_CLIENT_SECRET= +export OAUTH2_REDIRECT_URI=http://127.0.0.1:8000/oauth2/callback +veadk frontend --agents-dir examples +``` + +Google is the same with `OAUTH2_PROVIDER=google`; for any OIDC provider +(Keycloak / Auth0 / Okta / …) set `OAUTH2_ISSUER` plus the client credentials. A +full example lives at `examples/front_with_sso/`. + + + When deploying to a runtime / public host, the OAuth callback must point at an + externally reachable URL: set `OAUTH2_REDIRECT_URI` to the public callback URL + and register the same value in your OAuth app. The cookie `Secure` flag is + enabled automatically when that URL is HTTPS. + + **No SSO (local username)** — without those flags, the login page asks for a username (letters + digits, ≤16), stored locally and used as the `user_id`. diff --git a/docs/content/docs/framework/frontend/web-ui.mdx b/docs/content/docs/framework/frontend/web-ui.mdx index 06609248..8840c2b0 100644 --- a/docs/content/docs/framework/frontend/web-ui.mdx +++ b/docs/content/docs/framework/frontend/web-ui.mdx @@ -5,6 +5,16 @@ description: "内置 React 前端,用于渲染智能体流式返回的 A2UI, VeADK 自带一个 React 前端,用于渲染智能体经由 Google ADK API Server 流式返回的 [A2UI](/cn/docs/framework/frontend/a2ui)(智能体驱动 UI)。它与 `veadk frontend` 启动的是同一个服务进程,因此**无需单独部署后端**。 +## 功能一览 + +- **对话**:与智能体多轮对话,渲染流式返回的 A2UI 卡片,并展示思考过程、工具调用、Token 与用时。 +- **Agent 选择器**:左上角切换 agent;悬停可查看该 agent 的模型与挂载的工具。 +- **历史会话**:自动保存、按时间排序,可重新打开或删除。 +- **智能搜索**:「会话」源在当前 agent 的历史消息中做全文检索;「网页」源调用该 agent 挂载的联网搜索工具实时检索(使用服务端环境变量里的凭据)。 +- **添加 AgentKit 智能体**:填入访问地址 + API Key,按 ADK 协议接入远程 agent,接入后出现在选择器中。 +- **Tracing 观测**:查看本次会话的调用火焰图。 +- **登录**:支持 SSO(VeIdentity / GitHub / Google / 任意 OIDC)或本地用户名。 + ## 运行 先构建前端,再用一条命令同时提供 UI 与智能体 API: @@ -39,7 +49,8 @@ cd frontend && npm run dev # http://localhost:5173(代理 A | `--oauth2-user-pool` / `--oauth2-user-pool-client` | — | VeIdentity 用户池 / 客户端**名称**。设置后启用 SSO。 | | `--oauth2-user-pool-uid` / `--oauth2-user-pool-client-uid` | 环境变量 `OAUTH2_USER_POOL_ID` / `OAUTH2_USER_POOL_CLIENT_ID` | 用 **UID** 代替名称指定用户池 / 客户端。 | | `--oauth2-provider` / `--oauth2-provider-label` | `veidentity` | 登录按钮的 provider 标识与显示文案。 | -| `--oauth2-redirect-uri` | `http://{host}:{port}/oauth2/callback` | OAuth2 回调地址。 | +| `--oauth2-redirect-uri` | `http://{host}:{port}/oauth2/callback` | OAuth2 回调地址(环境变量 `OAUTH2_REDIRECT_URI`)。部署到公网 / runtime 时需设置。 | +| 第三方 / 自定义 provider | 见下文 | 通过环境变量接入 GitHub / Google / 任意 OIDC,详见「认证」一节。 | 非开发模式下,若未找到已构建的前端目录,命令会报错提示先执行 `npm run build`(或改用 `--dev` 配合 Vite 开发服务器)。 @@ -60,6 +71,34 @@ veadk frontend --agents-dir examples \ 需要进程能拿到火山引擎凭证(AK/SK)。中间件保护 API、放行 SPA 外壳与 `GET /web/auth-config`,因此应用能加载并展示自己的登录页(而不是被直接重定向到 IdP)。登录按钮的文案/图标由配置驱动(`--oauth2-provider` / `--oauth2-provider-label`)。 +**第三方 / 自定义 OAuth2(环境变量)** —— 不依赖 VeIdentity 用户池时,只要设置 `OAUTH2_CLIENT_ID`(及密钥),即可接入 GitHub、Google 或任意 OAuth2/OIDC 登录。端点来源按以下顺序确定:内置预设(`OAUTH2_PROVIDER=github` / `google`)→ OIDC 自动发现(设置 `OAUTH2_ISSUER`)→ 显式端点(`OAUTH2_AUTHORIZE_URL` 等)。 + +| 环境变量 | 说明 | +| :-- | :-- | +| `OAUTH2_PROVIDER` | provider 标识:`github`、`google` 或自定义名。决定登录按钮文案与内置预设。 | +| `OAUTH2_CLIENT_ID` / `OAUTH2_CLIENT_SECRET` | OAuth2 客户端凭据。**设置 `OAUTH2_CLIENT_ID` 即启用通用 provider。** | +| `OAUTH2_ISSUER` | OIDC issuer 基址,端点自动发现(如 `https://accounts.google.com`)。 | +| `OAUTH2_AUTHORIZE_URL` / `OAUTH2_TOKEN_URL` / `OAUTH2_USERINFO_URL` | 显式端点(用于非 OIDC provider)。 | +| `OAUTH2_SCOPE` | 覆盖请求的 scope。 | +| `OAUTH2_PROVIDER_LABEL` | 覆盖登录按钮文案。 | +| `OAUTH2_REDIRECT_URI` | **回调地址**。部署到公网 / runtime 时设为公网回调,并在 OAuth 应用中登记同一地址;本地默认 `http://{host}:{port}/oauth2/callback`。 | + +GitHub(预设,仅需 id/secret): + +```bash +export OAUTH2_PROVIDER=github +export OAUTH2_CLIENT_ID= +export OAUTH2_CLIENT_SECRET= +export OAUTH2_REDIRECT_URI=http://127.0.0.1:8000/oauth2/callback +veadk frontend --agents-dir examples +``` + +Google 同理,把 `OAUTH2_PROVIDER` 换成 `google`;Keycloak / Auth0 / Okta 等任意 OIDC 则设 `OAUTH2_ISSUER` + 客户端凭据即可。完整示例见仓库 `examples/front_with_sso/`。 + + + 部署到 runtime / 公网时,OAuth 回调必须指向外部可访问的地址:把 `OAUTH2_REDIRECT_URI` 设为公网回调 URL,并在 OAuth 应用里登记同一地址。Cookie 的 `Secure` 标志会根据该地址是否为 HTTPS 自动开启。 + + **无 SSO(本地用户名)** —— 不传上述参数时,登录页会让用户输入一个用户名(字母 + 数字,≤16 位),保存在本地并作为 `user_id`。 diff --git a/examples/dogfooding/VEADK_COMPONENTS.md b/examples/dogfooding/VEADK_COMPONENTS.md new file mode 100644 index 00000000..708796ee --- /dev/null +++ b/examples/dogfooding/VEADK_COMPONENTS.md @@ -0,0 +1,302 @@ +# VeADK Components Reference + +Ground-truth reference for generating runnable VeADK agent projects. Everything +below is grounded in the real `veadk` API (`veadk/agent.py` and the examples +under `examples/`). Prefer the defaults; only enable a component when the +requirement actually needs it. + +## 1. Minimal agent skeleton (the only hard requirements) + +A VeADK project that the ADK API server / web UI can load is a Python package +(a directory with `__init__.py`) whose `agent.py` exposes a module-level +`root_agent`. + +`agent.py`: + +```python +from veadk import Agent + +INSTRUCTION = "You are a helpful assistant. Answer concisely in the user's language." + +agent = Agent( + name="my_agent", # snake_case, no spaces + description="一句话描述这个 agent。", # used in A2A / multi-agent routing + instruction=INSTRUCTION, +) + +# Required by the Google ADK agent loader. MUST be named `root_agent`. +root_agent = agent +``` + +`__init__.py`: + +```python +from . import agent + +__all__ = ["agent"] +``` + +Notes: +- Do NOT pass `model=...`. VeADK fills the model from config/settings + (`settings.model.*`). Never hardcode a foreign model id. +- `name` should be a valid identifier (snake_case). `description` may be Chinese. +- The instruction can reference session state with `{key}` placeholders (see + multi-agent below). + +## 2. Tools — plain Python functions + +A tool is just a function with type hints and a docstring. The docstring is what +the model reads to decide when/how to call it, so write it for the model. +Tools should return a `dict` (e.g. `{"result": ...}`). + +```python +from veadk import Agent + +def get_city_weather(city: str) -> dict[str, str]: + """Get the current weather for a city. + + Args: + city: The English name of the city, e.g. "Beijing". + + Returns: + A dict with a human-readable weather "result". + """ + data = {"beijing": "Sunny, 25°C", "shanghai": "Cloudy, 22°C"} + return {"result": data.get(city.lower().strip(), f"No data for {city}")} + +agent = Agent( + name="weather_agent", + description="查询天气的助手。", + instruction="Use `get_city_weather` to look up conditions, then answer.", + tools=[get_city_weather], +) +root_agent = agent +``` + +### Built-in tools + +VeADK ships ready-made tools under `veadk.tools.builtin_tools`. Add them the same +way (drop into `tools=[...]`). The most common one: + +```python +from veadk.tools.builtin_tools.web_search import web_search # Volcengine web search + +agent = Agent(name="search_agent", description="联网搜索助手。", + instruction="When a question needs fresh info, call `web_search` first.", + tools=[web_search]) +``` + +`web_search` needs Volcengine `VOLCENGINE_ACCESS_KEY` / `VOLCENGINE_SECRET_KEY` +in the environment. Only use built-in tools you are sure exist; otherwise define +plain function tools. + +## 3. Multi-agent (sub_agents + workflow agents) + +Two ways to compose agents: + +(a) An `Agent` with `sub_agents=[...]` can transfer control to specialists +(LLM-driven routing). Each sub-agent needs a clear `description`. + +```python +from veadk import Agent + +billing = Agent(name="billing_agent", description="处理账单相关问题。", + instruction="You handle billing questions.") +tech = Agent(name="tech_agent", description="处理技术支持问题。", + instruction="You handle technical support.") + +agent = Agent( + name="router_agent", + description="把用户问题路由到合适的子 agent。", + instruction="Route the user's question to the right sub-agent.", + sub_agents=[billing, tech], +) +root_agent = agent +``` + +(b) Fixed-order workflow with `SequentialAgent` (also `ParallelAgent`, +`LoopAgent`). Sub-agents share session state: each writes via `output_key`, the +next reads it with a `{key}` placeholder in its instruction. + +```python +from veadk import Agent +from veadk.agents.sequential_agent import SequentialAgent + +outliner = Agent(name="outliner", + instruction="Produce a tight 3-point outline.", + output_key="outline") +writer = Agent(name="writer", + instruction="Expand this outline into a paragraph:\n\n{outline}", + output_key="draft") + +root_agent = SequentialAgent( + name="content_pipeline", + description="把主题变成一段成稿。", + sub_agents=[outliner, writer], +) +``` + +Note: workflow agents (Sequential/Parallel/Loop) do not carry their own memory. + +## 4. Short-term memory (conversation context across turns) + +Session/conversation memory, keyed by `session_id`. Pass it to BOTH the `Agent` +and the `Runner` (the API server provides the Runner for you). + +```python +from veadk import Agent +from veadk.memory.short_term_memory import ShortTermMemory + +short_term_memory = ShortTermMemory(backend="local") # or backend="sqlite", + # local_database_path="./stm.db" +agent = Agent( + name="memory_agent", + description="记得多轮对话内容的助手。", + instruction="Remember what the user tells you.", + short_term_memory=short_term_memory, +) +root_agent = agent +``` + +`backend="local"` is in-memory; `backend="sqlite"` persists to a local file. + +## 5. Long-term memory (facts across sessions) + +Persists facts across different sessions/users. Attaching it gives the agent a +`load_memory` tool automatically. `auto_save_session=True` writes each finished +session into long-term memory. Needs `pip install "veadk-python[extensions]"`. + +```python +from veadk import Agent +from veadk.memory.long_term_memory import LongTermMemory + +long_term_memory = LongTermMemory(backend="local", app_name="my_app") +agent = Agent( + name="ltm_agent", + description="跨会话记住用户偏好的助手。", + instruction="When the user asks about something they told you before, " + "use the `load_memory` tool to recall it.", + long_term_memory=long_term_memory, + auto_save_session=True, +) +root_agent = agent +``` + +## 6. Knowledgebase / RAG + +A `KnowledgeBase` embeds documents into a vector backend. Attaching it adds a +retrieval tool automatically, so the agent grounds answers in your content. +`backend="local"` needs `pip install "veadk-python[extensions]"`. + +```python +from veadk import Agent +from veadk.knowledgebase import KnowledgeBase + +knowledgebase = KnowledgeBase(backend="local", index="company_faq") +# knowledgebase.add_from_directory("./docs") # ingest local docs (embedded on add) + +agent = Agent( + name="rag_agent", + description="基于知识库回答问题。", + instruction="Always consult the knowledge base first and answer from what " + "you retrieve. If it's not there, say so.", + knowledgebase=knowledgebase, +) +root_agent = agent +``` + +## 7. Structured output + +Force the reply to match a Pydantic model with `output_schema`. The reply is then +JSON matching that schema. Note: with `output_schema` set, the agent cannot call +tools or transfer to sub-agents. + +```python +from pydantic import BaseModel, Field +from veadk import Agent + +class Ticket(BaseModel): + summary: str = Field(description="One-line summary.") + priority: str = Field(description="One of: low, medium, high.") + +agent = Agent( + name="ticket_extractor", + description="把自由文本变成结构化工单。", + instruction="Extract a support ticket from the user's message.", + output_schema=Ticket, +) +root_agent = agent +``` + +## 8. Tracing / observability + +Attach a tracer via `tracers=[...]`. Every LLM/tool call becomes a span. By +default spans are collected in-memory (no credentials needed). Cloud exporters +(APMPlus / CozeLoop / TLS) are enabled via env vars `ENABLE_APMPLUS` / +`ENABLE_COZELOOP` / `ENABLE_TLS` = `true` plus their creds. + +```python +from veadk import Agent +from veadk.tracing.telemetry.opentelemetry_tracer import OpentelemetryTracer + +agent = Agent( + name="traced_agent", + description="带链路追踪的助手。", + instruction="You are a helpful assistant.", + tracers=[OpentelemetryTracer()], +) +root_agent = agent +``` + +## 9. A2UI (agent-driven rich UI) + +Set `enable_a2ui=True` to let the agent reply with declarative UI cards rendered +by a client. It appends a `send_a2ui_json_to_client` tool. Needs +`pip install "veadk-python[a2ui]"`. + +```python +from veadk import Agent + +agent = Agent( + name="a2ui_agent", + description="能返回富 UI 卡片的助手。", + instruction="When the answer is naturally visual (a status card, a list, a " + "small form), reply by calling `send_a2ui_json_to_client` with " + "A2UI JSON built from the catalog components (Card, Column, Row, " + "Text, Icon, Button...). Otherwise answer in plain text.", + enable_a2ui=True, +) +root_agent = agent +``` + +## 10. Authorization + +`enable_authz=True` adds an authorization check (a `before_agent_callback`) +gating the agent. Only set it when the requirement asks for access control. + +```python +agent = Agent(name="secure_agent", description="需要鉴权的助手。", + instruction="...", enable_authz=True) +``` + +## Field cheat-sheet (Agent) + +| Field | Type | Purpose | +|---|---|---| +| `name` | `str` | snake_case identifier | +| `description` | `str` | used in routing / A2A | +| `instruction` | `str` | system prompt; supports `{state_key}` placeholders | +| `tools` | `list` | plain functions or built-in tools | +| `sub_agents` | `list[BaseAgent]` | child agents for routing | +| `output_key` | `str` | write reply into session state under this key | +| `output_schema` | `BaseModel` | force structured JSON output | +| `short_term_memory` | `ShortTermMemory` | per-session memory | +| `long_term_memory` | `LongTermMemory` | cross-session memory (adds `load_memory`) | +| `auto_save_session` | `bool` | persist each session to long-term memory | +| `knowledgebase` | `KnowledgeBase` | RAG; adds retrieval tool | +| `tracers` | `list[BaseTracer]` | observability spans | +| `enable_a2ui` | `bool` | agent-driven rich UI | +| `enable_authz` | `bool` | authorization gate | + +Do NOT pass `model=`; VeADK reads it from config. Always end `agent.py` with +`root_agent = `. diff --git a/examples/dogfooding/__init__.py b/examples/dogfooding/__init__.py new file mode 100644 index 00000000..e1f5efff --- /dev/null +++ b/examples/dogfooding/__init__.py @@ -0,0 +1,17 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import agent + +__all__ = ["agent"] diff --git a/examples/dogfooding/agent.py b/examples/dogfooding/agent.py new file mode 100644 index 00000000..eae6cdcf --- /dev/null +++ b/examples/dogfooding/agent.py @@ -0,0 +1,97 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Agent builder: turn a natural-language requirement into a VeADK agent CONFIG. + +Given a user's requirement (in any language), this agent emits a single JSON +object describing the desired agent's CONFIGURATION — NOT code. The web UI then +feeds that config through the same code generator the "自定义模式" wizard uses, so +both modes share one templating path. It is served by the ADK API server like the +other examples and powers the web UI's "智能模式" (smart mode). +""" + +import os + +from veadk import Agent + +# The schema mirrors the web UI's AgentDraft and the codegen catalog +# (frontend/src/create/veadkCatalog.ts). Output values MUST come from the +# enumerations below so the generator can assemble valid VeADK code. +INSTRUCTION = r"""你是 **VeADK Agent Builder**。把用户的自然语言需求,转化成一份**智能体配置 JSON** +(注意:不是代码,而是配置;前端会用同一套模板把配置组装成 VeADK 代码)。 + +你必须**只输出一个 JSON 对象**,不要任何解释、不要 Markdown 代码围栏。结构如下(只填需要的字段,其余可省略,省略即用默认值): + +{ + "name": "snake_case 英文标识,如 weather_assistant", + "description": "一句话中文描述这个 agent", + "instruction": "系统提示词:定义角色、目标、行为边界(可中文)", + "builtinTools": [], // 见下方「内置工具」枚举,按需选择 + "customTools": [ // 需要但没有内置工具时,声明自定义函数工具(用户后续实现) + {"name": "查询订单", "description": "根据订单号查询订单状态"} + ], + "memory": {"shortTerm": false, "longTerm": false}, + "shortTermBackend": "local", // local | sqlite | mysql | postgresql + "longTermBackend": "local", // local | opensearch | redis | viking | mem0 + "autoSaveSession": false, // 开启长期记忆时,是否自动落库会话 + "knowledgebase": false, + "knowledgebaseBackend": "local", // local | opensearch | viking | context_search + "tracing": false, + "tracingExporters": [], // 子集: apmplus | cozeloop | tls + "enableA2ui": false, // 需要返回富 UI 卡片时为 true + "subAgents": [ // 需要多角色/多步骤协作时使用 + {"name": "billing", "description": "处理账单", "instruction": "...", + "builtinTools": [], "customTools": []} + ] +} + +「内置工具」builtinTools 可选值(只能用这些 id): +- web_search 联网搜索(实时信息) +- parallel_web_search 并行联网搜索 +- link_reader 读取网页正文 +- web_scraper 结构化爬取网页 +- image_generate 文生图 +- image_edit 图像编辑 +- video_generate 文/图生视频 +- text_to_speech 语音合成 +- vesearch VeSearch 智能搜索 + +判断规则: +- 总是给出 name(snake_case)、description、instruction。 +- 需求需要某种现成能力且命中上面的内置工具 → 放进 builtinTools;否则用 customTools 声明函数工具(给中文 name + description)。 +- 只有当需求确实需要时,才开启 memory / knowledgebase / tracing;后端默认 "local",无明确要求就用 local。 +- 多轮上下文 → memory.shortTerm=true;跨会话记忆 → memory.longTerm=true(通常配 autoSaveSession=true)。 +- 需要基于资料问答 → knowledgebase=true。 +- 需要返回卡片/表单等富 UI → enableA2ui=true。 +- 多角色/分工/流水线 → 用 subAgents 拆分。 + +仅当用户需求**为空**时,用一句简短中文澄清问题代替 JSON。否则直接输出配置 JSON,不要反问。 +""" + +# Optional model override for the A/B builder (slot A). Set AGENT_BUILDER_MODEL_A +# in the env to pin this builder to a specific model; otherwise it uses the +# VeADK default model. Slot B lives in ../dogfooding_b. +_MODEL_A = os.getenv("AGENT_BUILDER_MODEL_A", "").strip() + +# Pass the instruction as a provider (callable) so ADK does NOT treat the literal +# `{...}` JSON braces as session-state template variables. +agent = Agent( + name="agent_builder", + description="VeADK Agent Builder:把自然语言需求转化为智能体配置 JSON(前端据此生成 VeADK 项目)。", + instruction=lambda _ctx: INSTRUCTION, + **({"model_name": _MODEL_A} if _MODEL_A else {}), +) + +# Required by the Google ADK agent loader. +root_agent = agent diff --git a/examples/dogfooding_b/__init__.py b/examples/dogfooding_b/__init__.py new file mode 100644 index 00000000..e1f5efff --- /dev/null +++ b/examples/dogfooding_b/__init__.py @@ -0,0 +1,17 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import agent + +__all__ = ["agent"] diff --git a/examples/dogfooding_b/agent.py b/examples/dogfooding_b/agent.py new file mode 100644 index 00000000..b746fc35 --- /dev/null +++ b/examples/dogfooding_b/agent.py @@ -0,0 +1,46 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Agent builder — slot B for the web UI's A/B compare mode. + +Identical to ``dogfooding`` (same config-JSON instruction) but a SEPARATE ADK app +so the web UI can run two builders in parallel and diff their designs. Pin its +model with the env var ``AGENT_BUILDER_MODEL_B`` (otherwise it uses the VeADK +default model). The shared instruction is imported from the ``dogfooding`` app so +the two stay in sync. +""" + +import os + +from veadk import Agent + +# The two builder apps share one instruction. ADK puts the agents dir on +# sys.path, so the sibling app package is importable; fall back to the +# fully-qualified path when imported as part of the `examples` package. +try: + from dogfooding.agent import INSTRUCTION +except ImportError: # pragma: no cover - depends on how the loader sets sys.path + from examples.dogfooding.agent import INSTRUCTION + +_MODEL_B = os.getenv("AGENT_BUILDER_MODEL_B", "").strip() + +agent = Agent( + name="agent_builder_b", + description="VeADK Agent Builder (B):A/B 对比中的第二个构建器。", + instruction=lambda _ctx: INSTRUCTION, + **({"model_name": _MODEL_B} if _MODEL_B else {}), +) + +# Required by the Google ADK agent loader. +root_agent = agent diff --git a/examples/front_with_sso/README.md b/examples/front_with_sso/README.md new file mode 100644 index 00000000..799a9b1a --- /dev/null +++ b/examples/front_with_sso/README.md @@ -0,0 +1,88 @@ +# front_with_sso + +Try `veadk frontend` with an SSO login page, where the OAuth2 provider is +configured **entirely via environment variables** — no VeIdentity user pool +needed. Works with GitHub, Google, any OIDC provider, or a fully custom one. + +When SSO is enabled, an unauthenticated browser sees a login page; after signing +in, the signed-in identity becomes the ADK `user_id`. + +## How provider resolution works + +`veadk frontend` picks the SSO backend like this: + +1. If a VeIdentity user pool is configured (`--oauth2-user-pool*`), it uses that. +2. Otherwise, if `OAUTH2_CLIENT_ID` is set, it builds a **generic** provider from + env vars (this example). +3. Otherwise, no SSO (local username mode). + +Endpoints for the generic provider come from, in order: + +- a built-in **preset** (`OAUTH2_PROVIDER=github` or `google`), or +- **OIDC discovery** when `OAUTH2_ISSUER` is set (`/.well-known/openid-configuration`), or +- **explicit** `OAUTH2_AUTHORIZE_URL` / `OAUTH2_TOKEN_URL` / `OAUTH2_USERINFO_URL`. + +## Environment variables + +| Variable | Purpose | +|---|---| +| `OAUTH2_PROVIDER` | Provider id: `github`, `google`, or a custom name. Drives the login button label and the preset. | +| `OAUTH2_CLIENT_ID` | OAuth2 client id. **Setting this enables the generic provider.** | +| `OAUTH2_CLIENT_SECRET` | OAuth2 client secret. | +| `OAUTH2_ISSUER` | OIDC issuer base URL (endpoints auto-discovered). e.g. `https://accounts.google.com`. | +| `OAUTH2_AUTHORIZE_URL` / `OAUTH2_TOKEN_URL` / `OAUTH2_USERINFO_URL` | Explicit endpoints (for non-OIDC providers). | +| `OAUTH2_SCOPE` | Override the requested scopes. | +| `OAUTH2_PROVIDER_LABEL` | Override the login button text. | +| `OAUTH2_REDIRECT_URI` | **Callback URL.** Set this when deploying behind a public host/runtime; otherwise it defaults to `http://{host}:{port}/oauth2/callback`. The value must be registered as an authorized callback in your OAuth app. | + +## Run + +GitHub (preset — only id/secret needed): + +```bash +export OAUTH2_PROVIDER=github +export OAUTH2_CLIENT_ID= +export OAUTH2_CLIENT_SECRET= +# GitHub OAuth app "Authorization callback URL" must equal this: +export OAUTH2_REDIRECT_URI=http://127.0.0.1:8000/oauth2/callback + +# run from the parent folder so this example is a selectable agent +veadk frontend --agents-dir examples +# open http://127.0.0.1:8000 -> "使用 GitHub 登录" +``` + +Google (OIDC preset): + +```bash +export OAUTH2_PROVIDER=google +export OAUTH2_CLIENT_ID=<...>.apps.googleusercontent.com +export OAUTH2_CLIENT_SECRET=<...> +export OAUTH2_REDIRECT_URI=http://127.0.0.1:8000/oauth2/callback +veadk frontend --agents-dir examples +``` + +Any OIDC provider (Keycloak / Auth0 / Okta / …) via discovery: + +```bash +export OAUTH2_PROVIDER=mycorp +export OAUTH2_PROVIDER_LABEL="MyCorp SSO" +export OAUTH2_ISSUER=https://id.mycorp.com # /.well-known/openid-configuration +export OAUTH2_CLIENT_ID=<...> +export OAUTH2_CLIENT_SECRET=<...> +export OAUTH2_REDIRECT_URI=https://chat.mycorp.com/oauth2/callback +veadk frontend --host 0.0.0.0 --port 8000 +``` + +A convenience launcher with the GitHub variables wired up: + +```bash +bash examples/front_with_sso/run.sh +``` + +## Deploying behind a public host / runtime + +The OAuth callback must come back to a URL the IdP knows. Locally that is +`http://127.0.0.1:8000/oauth2/callback`, but on a runtime the public host +differs — set `OAUTH2_REDIRECT_URI` to the public callback URL and register the +same value in your OAuth app. The launcher also derives the post-login/logout +origin and the cookie `Secure` flag from this URL (HTTPS → Secure cookies). diff --git a/examples/front_with_sso/agent.py b/examples/front_with_sso/agent.py new file mode 100644 index 00000000..ef8a132c --- /dev/null +++ b/examples/front_with_sso/agent.py @@ -0,0 +1,31 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A minimal agent for trying `veadk frontend` behind SSO login. + +The SSO provider is configured entirely through environment variables on the +`veadk frontend` process — no VeIdentity user pool required. See README.md for +the GitHub / Google / custom-OIDC env vars and how to run it. +""" + +from veadk import Agent + +agent = Agent( + name="sso_demo_agent", + description="Demo agent served behind an SSO login page.", + instruction="You are a helpful assistant. Answer concisely.", +) + +# Required by the Google ADK agent loader. +root_agent = agent diff --git a/examples/front_with_sso/run.sh b/examples/front_with_sso/run.sh new file mode 100644 index 00000000..c852defe --- /dev/null +++ b/examples/front_with_sso/run.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Launch `veadk frontend` with a GitHub SSO login, configured via env vars. +# Fill in your GitHub OAuth app's client id/secret, then: bash run.sh +# +# GitHub OAuth app setup: https://github.com/settings/developers -> New OAuth App +# Authorization callback URL must equal $OAUTH2_REDIRECT_URI below. +set -euo pipefail + +export OAUTH2_PROVIDER="${OAUTH2_PROVIDER:-github}" +export OAUTH2_CLIENT_ID="${OAUTH2_CLIENT_ID:-REPLACE_ME}" +export OAUTH2_CLIENT_SECRET="${OAUTH2_CLIENT_SECRET:-REPLACE_ME}" +export OAUTH2_REDIRECT_URI="${OAUTH2_REDIRECT_URI:-http://127.0.0.1:8000/oauth2/callback}" + +if [ "$OAUTH2_CLIENT_ID" = "REPLACE_ME" ]; then + echo "Set OAUTH2_CLIENT_ID / OAUTH2_CLIENT_SECRET first (see README.md)." >&2 + exit 1 +fi + +# Run from the repo root so this example is one of the selectable agents. +cd "$(dirname "$0")/../.." +exec veadk frontend --agents-dir examples --host 127.0.0.1 --port 8000 diff --git a/examples/web_demo/__init__.py b/examples/web_demo/__init__.py new file mode 100644 index 00000000..e1f5efff --- /dev/null +++ b/examples/web_demo/__init__.py @@ -0,0 +1,17 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import agent + +__all__ = ["agent"] diff --git a/examples/web_demo/agent.py b/examples/web_demo/agent.py new file mode 100644 index 00000000..8491b589 --- /dev/null +++ b/examples/web_demo/agent.py @@ -0,0 +1,38 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A minimal, spec-conforming agent for the VeADK Web UI. + +Launch convention (same as `adk web`): run `veadk frontend` from the directory +*above* the agent folders. Every subdirectory whose name is a valid Python +module and that exposes a module-level ``root_agent`` becomes a selectable app +in the UI's dropdown (it is returned by ``/list-apps``). + + cd examples + veadk frontend # lists web_demo (and the other agent folders) +""" + +from veadk import Agent + +agent = Agent( + name="web_demo", + description="A general-purpose assistant demo for VeADK Web.", + instruction=( + "You are a helpful assistant. Answer clearly and concisely in the " + "user's language." + ), +) + +# Required by the ADK agent loader. +root_agent = agent diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4d61c90e..ef74f146 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "veadk-a2ui-frontend", "version": "0.1.0", "dependencies": { + "@xyflow/react": "^12.11.0", "highlight.js": "^11.11.1", "lucide-react": "^0.460.0", "motion": "^11.18.2", @@ -15,7 +16,8 @@ "react-dom": "^18.3.1", "react-markdown": "^9.1.0", "rehype-highlight": "^7.0.2", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "yaml": "^2.9.0" }, "devDependencies": { "@types/react": "^18.3.12", @@ -1150,6 +1152,55 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.13.tgz", @@ -1218,7 +1269,7 @@ "version": "18.3.7", "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -1257,6 +1308,48 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.11.0", + "resolved": "https://registry.npmmirror.com/@xyflow/react/-/react-12.11.0.tgz", + "integrity": "sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.77", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.77", + "resolved": "https://registry.npmmirror.com/@xyflow/system/-/system-0.0.77.tgz", + "integrity": "sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz", @@ -1385,6 +1478,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmmirror.com/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -1408,6 +1507,111 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", @@ -3257,6 +3461,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", @@ -3352,6 +3565,49 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index cf6c3472..9ac96fb4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@xyflow/react": "^12.11.0", "highlight.js": "^11.11.1", "lucide-react": "^0.460.0", "motion": "^11.18.2", @@ -17,7 +18,8 @@ "react-dom": "^18.3.1", "react-markdown": "^9.1.0", "rehype-highlight": "^7.0.2", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "yaml": "^2.9.0" }, "devDependencies": { "@types/react": "^18.3.12", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 83439b86..b5603b96 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Check, Copy, FileText, Loader2 } from "lucide-react"; import { motion } from "motion/react"; import { @@ -13,8 +13,46 @@ import { } from "./adk/client"; import { applyEvent, emptyAcc, eventsToTurns, type Turn } from "./blocks"; import { Sidebar } from "./ui/Sidebar"; +import { Navbar } from "./ui/Navbar"; +import { SkillCenterView } from "./ui/SkillCenter"; +import { AddAgentKitView } from "./ui/AddAgentKit"; +import { SearchView } from "./ui/Search"; +import { + buildAgentEntries, + loadConnections, + registerConnections, + remoteAppId, + type RemoteConnection, +} from "./adk/connections"; import { Blocks, ThinkingPlaceholder } from "./ui/Blocks"; import { Composer } from "./ui/Composer"; +import { QuickCreate, type QuickCreateKind } from "./ui/QuickCreate"; +import { StackCards } from "./ui/AddAgentMenu"; +import { IntelligentCreate } from "./create/IntelligentCreate"; +import { CustomCreate } from "./create/CustomCreate"; +import { TemplateCreate } from "./create/TemplateCreate"; +import { WorkflowCreate } from "./create/WorkflowCreate"; +import type { AgentDraft } from "./create/types"; + +// Breadcrumb root label for the create flow and the per-mode leaf labels. +const CREATE_ROOT = "创建 Agent"; +const MODE_LABEL: Record = { + intelligent: "智能模式", + custom: "自定义", + template: "从模板新建", + workflow: "工作流", +}; + +type CreateView = "menu" | QuickCreateKind | null; + +// Persist the last view so a page refresh restores where the user was. +const LS = { app: "veadk.appName", view: "veadk.view", session: "veadk.sessionId" } as const; +function loadView(): CreateView { + const v = typeof localStorage !== "undefined" ? localStorage.getItem(LS.view) : null; + return v === "menu" || v === "intelligent" || v === "custom" || v === "template" || v === "workflow" + ? v + : null; +} import { TraceDrawer } from "./ui/TraceDrawer"; import { LoginPage } from "./ui/LoginPage"; import { Markdown } from "./ui/Markdown"; @@ -28,6 +66,31 @@ import { } from "./adk/identity"; import type { A2uiAction, A2uiComponent } from "./a2ui/types"; +/** Hand-drawn "AgentKit" mark: a little agent/robot module with side ports — + * a packaged remote agent you plug into. */ +function AgentKitIcon({ className }: { className?: string }) { + return ( + + + + + + + + + ); +} + +/** Hand-drawn "from zero" mark: a "0" ring with a creativity spark inside. */ +function ScratchIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + /** Hand-drawn "tracing / observability" icon (stacked spans). */ function TraceIcon() { return ( @@ -138,6 +201,36 @@ export default function App() { const [userInfo, setUserInfo] = useState | undefined>(); const [localMode, setLocalMode] = useState(false); const [loadingSession, setLoadingSession] = useState(false); + const [createView, setCreateView] = useState(loadView); + const [skillCenter, setSkillCenter] = useState(false); + const [addAgent, setAddAgent] = useState(false); + // The "添加 Agent" chooser (two cards: AgentKit / 从 0 快速创建). + const [addMenu, setAddMenu] = useState(false); + // A draft imported from YAML, used to pre-fill the custom wizard once. + const [importedDraft, setImportedDraft] = useState(null); + const [searchView, setSearchView] = useState(false); + // A search result may belong to a different agent; remember it so the + // agent-switch effect opens it instead of resetting to a fresh chat. + const pendingOpenRef = useRef<{ app: string; sid: string } | null>(null); + // Remote AgentKit connections (persisted); register them into the ADK client + // routing table once, synchronously, so remote app ids resolve immediately. + const [connections, setConnections] = useState(() => { + const c = loadConnections(); + registerConnections(c); + return c; + }); + // Shown when the user clicks the breadcrumb root to leave a create mode; + // warns that the in-progress draft will be discarded. + const [confirmLeave, setConfirmLeave] = useState(false); + // Restore the previously-open session only once, after apps/user resolve. + const restoredRef = useRef(false); + + // Placeholder: persisting/registering the created agent is a follow-up. + function onCreate(draft: AgentDraft) { + console.log("create agent draft:", draft); + setCreateView(null); + startNewChat(); + } const { ref: scrollRef, onScroll } = useStickToBottom(turns); // Resolve SSO identity first; it provides the ADK user_id. @@ -174,22 +267,70 @@ export default function App() { listApps() .then((list) => { setApps(list); - const preferred = list.find((a) => a.includes("a2ui")) ?? list[0]; + // Restore the last-used agent; otherwise pick the first one. + const saved = localStorage.getItem(LS.app); + const remoteIds = connections.flatMap((c) => c.apps.map((a) => remoteAppId(c.id, a))); + const valid = saved && (list.includes(saved) || remoteIds.includes(saved)); + const preferred = valid ? saved : list[0]; if (preferred) setAppName(preferred); }) .catch((e) => setError(String(e))); }, [authStatus]); - // When the app (or resolved user) changes: reset to a fresh chat and list - // existing sessions. No session is created until the first message is sent. + // Persist the current view/agent/session so a refresh restores them. + useEffect(() => { + if (appName) localStorage.setItem(LS.app, appName); + }, [appName]); + useEffect(() => { + localStorage.setItem(LS.view, createView ?? "chat"); + }, [createView]); + useEffect(() => { + localStorage.setItem(LS.session, sessionId); + }, [sessionId]); + + // When the app (or resolved user) changes, list existing sessions. On the + // very first resolve, restore the previously-open session (if it still + // exists and we weren't on a create view); otherwise start a fresh chat. useEffect(() => { if (!appName || !userId) return; - startNewChat(); - void refreshSessions(appName); + (async () => { + const list = await refreshSessions(appName); + if (!restoredRef.current) { + restoredRef.current = true; + const savedId = localStorage.getItem(LS.session) || ""; + if (loadView() === null && savedId && list.some((s) => s.id === savedId)) { + void pickSession(savedId); + return; + } + } + startNewChat(); + })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [appName, userId]); - async function refreshSessions(app: string) { + // After switching agent from a search result, open the target session (runs + // after the agent-switch effect above, so it wins over its startNewChat()). + useEffect(() => { + const p = pendingOpenRef.current; + if (p && p.app === appName) { + pendingOpenRef.current = null; + void pickSession(p.sid); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [appName]); + + // Open a session surfaced by search, switching agent first if needed. + function openFromSearch(app: string, sid: string) { + setSearchView(false); + if (app === appName) { + void pickSession(sid); + } else { + pendingOpenRef.current = { app, sid }; + setAppName(app); + } + } + + async function refreshSessions(app: string): Promise { try { const list = await listSessions(app, userId); // Hydrate events so the sidebar can show a title per session. @@ -199,8 +340,10 @@ export default function App() { ), ); setSessions(hydrated); + return hydrated; } catch (e) { setError(String(e)); + return []; } } @@ -266,6 +409,16 @@ export default function App() { try { sid = await createSession(appName, userId); setSessionId(sid); + // Show the session in the sidebar immediately (titled with this first + // message) instead of waiting for the reply to finish; the post-stream + // refreshSessions() below reconciles it with the server. + const now = Date.now() / 1000; + const optimistic: AdkSession = { + id: sid, + lastUpdateTime: now, + events: [{ author: "user", timestamp: now, content: { role: "user", parts: [{ text }] } }], + }; + setSessions((prev) => [optimistic, ...prev.filter((s) => s.id !== sid)]); } catch (e) { setError(String(e)); setBusy(false); @@ -339,16 +492,59 @@ export default function App() { return (
startNewChat()} - onPickSession={pickSession} + onNewChat={() => { + setCreateView(null); + setSkillCenter(false); + setAddAgent(false); + setAddMenu(false); + setSearchView(false); + startNewChat(); + }} + onSearch={() => { + setCreateView(null); + setSkillCenter(false); + setAddAgent(false); + setAddMenu(false); + setSearchView(true); + }} + onQuickCreate={() => { + // "添加 Agent" — open the two-card chooser. Drop any selected session. + setSessionId(""); + setTurns([]); + setSkillCenter(false); + setAddAgent(false); + setSearchView(false); + setCreateView(null); + setImportedDraft(null); + setAddMenu(true); + }} + onSkillCenter={() => { + setCreateView(null); + setAddAgent(false); + setAddMenu(false); + setSearchView(false); + setSkillCenter(true); + }} + onAddAgent={() => { + setCreateView(null); + setSkillCenter(false); + setSearchView(false); + setSessionId(""); + setTurns([]); + setAddMenu(false); + setAddAgent(true); + }} + onPickSession={(id) => { + setCreateView(null); + setSkillCenter(false); + setAddAgent(false); + setAddMenu(false); + setSearchView(false); + pickSession(id); + }} onDeleteSession={removeSession} - userInfo={userInfo} - onLogout={onLogout} /> {(() => { @@ -372,8 +568,47 @@ export default function App() { } /> ); + const agentEntries = buildAgentEntries(apps, connections); + const labelOf = (id: string) => agentEntries.find((e) => e.id === id)?.label ?? id; return (
+ e.id)} + appName={appName} + onAppChange={setAppName} + agentLabel={labelOf} + userInfo={userInfo} + onLogout={onLogout} + title={ + addMenu + ? "添加 Agent" + : addAgent + ? "添加 AgentKit 智能体" + : skillCenter + ? "技能中心" + : undefined + } + crumbs={ + searchView || addAgent || skillCenter || addMenu || !createView + ? undefined + : createView === "menu" + ? [ + { + label: CREATE_ROOT, + onClick: () => { + setCreateView(null); + setImportedDraft(null); + setAddMenu(true); + }, + }, + { label: "从 0 快速创建" }, + ] + : [ + { label: "从 0 快速创建", onClick: () => setConfirmLeave(true) }, + { label: MODE_LABEL[createView] }, + ] + } + /> {error &&
{error}
} {loadingSession && (
@@ -381,7 +616,76 @@ export default function App() {
)} - {turns.length === 0 ? ( + {addMenu ? ( + { + setAddMenu(false); + setAddAgent(true); + }, + }, + { + key: "scratch", + icon: ScratchIcon, + title: "从 0 快速创建", + desc: "用智能 / 自定义 / 模板 / 工作流的方式从零创建一个 Agent。", + onClick: () => { + setAddMenu(false); + setImportedDraft(null); + setCreateView("menu"); + }, + }, + ]} + /> + ) : searchView ? ( + + ) : addAgent ? ( + { + setConnections(loadConnections()); + setAddAgent(false); + setAppName(id); + }} + onCancel={() => setAddAgent(false)} + /> + ) : skillCenter ? ( + + ) : createView === "menu" ? ( + { + setImportedDraft(null); + setCreateView(k); + }} + onImport={(d) => { + setImportedDraft(d); + setCreateView("custom"); + }} + /> + ) : createView === "intelligent" ? ( + setCreateView("menu")} onCreate={onCreate} /> + ) : createView === "custom" ? ( + setCreateView("menu")} + onCreate={onCreate} + /> + ) : createView === "template" ? ( + setCreateView("menu")} onCreate={onCreate} /> + ) : createView === "workflow" ? ( + setCreateView("menu")} onCreate={onCreate} /> + ) : turns.length === 0 ? (

{greeting}

{composer} @@ -478,6 +782,30 @@ export default function App() { {traceOpen && sessionId && ( setTraceOpen(false)} /> )} + + {confirmLeave && ( +
setConfirmLeave(false)}> +
e.stopPropagation()}> +
返回创建首页?
+
返回后当前填写的内容将会丢失,确定要返回吗?
+
+ + +
+
+
+ )}
); } diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts index f57f2a4f..b5c8fe00 100644 --- a/frontend/src/adk/client.ts +++ b/frontend/src/adk/client.ts @@ -80,8 +80,43 @@ export interface Attachment { const API_BASE = ""; // same origin (prod) / proxied (dev) -/** fetch wrapper that forwards the gateway auth querystring on every request. */ -function apiFetch(path: string, init: RequestInit = {}): Promise { +/** A resolved ADK endpoint. Empty `base` = the local same-origin server. */ +export interface AdkEndpoint { + base?: string; + apiKey?: string; +} + +// Routing table for remote AgentKit apps: maps a dropdown id (see +// adk/connections.ts) to its real ADK app name + endpoint. Local apps are not +// registered and fall through to the same-origin server. +interface RemoteApp { + app: string; + base: string; + apiKey: string; +} +const remoteApps = new Map(); + +export function registerRemoteApp(id: string, info: RemoteApp): void { + remoteApps.set(id, info); +} +export function clearRemoteApps(): void { + remoteApps.clear(); +} + +/** Resolve a dropdown id to its real ADK app name + endpoint. */ +function resolve(appName: string): { app: string; ep: AdkEndpoint } { + const r = remoteApps.get(appName); + return r ? { app: r.app, ep: { base: r.base, apiKey: r.apiKey } } : { app: appName, ep: {} }; +} + +/** fetch wrapper: same-origin (forwarding the gateway auth querystring) for the + * local server, or a remote AgentKit base URL with a Bearer API key. */ +function apiFetch(path: string, init: RequestInit = {}, ep: AdkEndpoint = {}): Promise { + if (ep.base) { + const headers: Record = { ...(init.headers as Record) }; + if (ep.apiKey) headers["Authorization"] = `Bearer ${ep.apiKey}`; + return fetch(ep.base.replace(/\/+$/, "") + path, { ...init, headers }); + } return fetch(withAuth(`${API_BASE}${path}`), init); } @@ -91,15 +126,23 @@ export async function listApps(): Promise { return res.json(); } +/** List the apps a remote AgentKit server exposes (also validates URL + key). */ +export async function fetchRemoteApps(base: string, apiKey: string): Promise { + const res = await apiFetch(`/list-apps`, {}, { base, apiKey }); + if (!res.ok) throw new Error(`list-apps failed: ${res.status}`); + return res.json(); +} + export async function createSession( appName: string, userId: string, ): Promise { - const res = await apiFetch(`/apps/${appName}/users/${encodeURIComponent(userId)}/sessions`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "{}", - }); + const { app, ep } = resolve(appName); + const res = await apiFetch( + `/apps/${app}/users/${encodeURIComponent(userId)}/sessions`, + { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }, + ep, + ); if (!res.ok) throw new Error(`create session failed: ${res.status}`); const session = await res.json(); return session.id; @@ -109,7 +152,8 @@ export async function listSessions( appName: string, userId: string, ): Promise { - const res = await apiFetch(`/apps/${appName}/users/${encodeURIComponent(userId)}/sessions`); + const { app, ep } = resolve(appName); + const res = await apiFetch(`/apps/${app}/users/${encodeURIComponent(userId)}/sessions`, {}, ep); if (!res.ok) throw new Error(`list sessions failed: ${res.status}`); return res.json(); } @@ -119,7 +163,12 @@ export async function getSession( userId: string, sessionId: string, ): Promise { - const res = await apiFetch(`/apps/${appName}/users/${encodeURIComponent(userId)}/sessions/${sessionId}`); + const { app, ep } = resolve(appName); + const res = await apiFetch( + `/apps/${app}/users/${encodeURIComponent(userId)}/sessions/${sessionId}`, + {}, + ep, + ); if (!res.ok) throw new Error(`get session failed: ${res.status}`); return res.json(); } @@ -129,9 +178,12 @@ export async function deleteSession( userId: string, sessionId: string, ): Promise { - const res = await apiFetch(`/apps/${appName}/users/${encodeURIComponent(userId)}/sessions/${sessionId}`, { - method: "DELETE", - }); + const { app, ep } = resolve(appName); + const res = await apiFetch( + `/apps/${app}/users/${encodeURIComponent(userId)}/sessions/${sessionId}`, + { method: "DELETE" }, + ep, + ); if (!res.ok && res.status !== 404) throw new Error(`delete session failed: ${res.status}`); } @@ -141,6 +193,47 @@ export async function getSessionTrace(sessionId: string): Promise { return res.json(); } +/** Introspected metadata for an agent app (model, tools), for the picker. + * Only the local server implements `/web/agent-info`; remote AgentKit apps + * will reject this and the caller falls back to a basic flyout. */ +export interface AgentInfo { + name: string; + description: string; + model: string; + tools: string[]; + subAgents: string[]; +} + +export async function getAgentInfo(appName: string): Promise { + const { app, ep } = resolve(appName); + const res = await apiFetch(`/web/agent-info/${app}`, {}, ep); + if (!res.ok) throw new Error(`agent-info failed: ${res.status}`); + return res.json(); +} + +/** One web-search hit (Volcengine WebSearch WebItem, trimmed for the UI). */ +export interface WebHit { + title: string; + url: string; + siteName: string; + summary: string; +} + +/** Run an agent's web-search tool on the local server (which holds the env + * credentials). `mounted` is false when a known agent has no web-search tool; + * `error` is set when the search ran but the API reported a problem. */ +export async function webSearch( + appName: string, + query: string, +): Promise<{ mounted: boolean; results: WebHit[]; error?: string }> { + const { app } = resolve(appName); + const res = await apiFetch( + `/web/search?source=web&app_name=${encodeURIComponent(app)}&q=${encodeURIComponent(query)}`, + ); + if (!res.ok) throw new Error(`web search failed: ${res.status}`); + return res.json(); +} + export interface RunArgs { appName: string; userId: string; @@ -157,23 +250,28 @@ export async function* runSSE({ text, attachments = [], }: RunArgs): AsyncGenerator { + const { app, ep } = resolve(appName); const parts: AdkPart[] = [ ...attachments.map((a) => ({ inlineData: { mimeType: a.mimeType, data: a.data, displayName: a.name }, })), ...(text.trim() ? [{ text }] : []), ]; - const res = await apiFetch(`/run_sse`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - app_name: appName, - user_id: userId, - session_id: sessionId, - new_message: { role: "user", parts }, - streaming: true, - }), - }); + const res = await apiFetch( + `/run_sse`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + app_name: app, + user_id: userId, + session_id: sessionId, + new_message: { role: "user", parts }, + streaming: true, + }), + }, + ep, + ); if (!res.ok) throw new Error(`run_sse failed: ${res.status}`); for await (const evt of parseSSE(res)) { yield evt as AdkEvent; diff --git a/frontend/src/adk/connections.ts b/frontend/src/adk/connections.ts new file mode 100644 index 00000000..6084e65b --- /dev/null +++ b/frontend/src/adk/connections.ts @@ -0,0 +1,115 @@ +// Remote AgentKit connections: a URL + API key whose apps are reachable over +// the ADK protocol (browser-direct, with `Authorization: Bearer `). Stored +// in localStorage and registered into the client's routing table on load. + +import { clearRemoteApps, fetchRemoteApps, registerRemoteApp } from "./client"; + +export interface RemoteConnection { + id: string; + name: string; + base: string; + apiKey: string; + apps: string[]; +} + +/** An entry in the agent picker — a local app or one remote AgentKit app. */ +export interface AgentEntry { + id: string; // selection id passed to the ADK client + label: string; // shown in the dropdown + app: string; // real ADK app name + remote: boolean; + host?: string; // remote host, for display +} + +const STORAGE_KEY = "veadk_agentkit_connections"; + +export function loadConnections(): RemoteConnection[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? (JSON.parse(raw) as RemoteConnection[]) : []; + } catch { + return []; + } +} + +function persist(list: RemoteConnection[]): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(list)); + } catch { + /* storage unavailable */ + } +} + +/** Dropdown id for one remote app (kept distinct from local app names). */ +export function remoteAppId(connId: string, app: string): string { + return `agentkit:${connId}:${app}`; +} + +function hostOf(base: string): string { + try { + return new URL(base).host; + } catch { + return base; + } +} + +/** Register all stored connections' apps into the client routing table. */ +export function registerConnections(conns: RemoteConnection[]): void { + clearRemoteApps(); + for (const c of conns) { + for (const app of c.apps) { + registerRemoteApp(remoteAppId(c.id, app), { app, base: c.base, apiKey: c.apiKey }); + } + } +} + +/** Validate a remote AgentKit endpoint and persist it. Throws on bad URL/key. */ +export async function addConnection( + name: string, + base: string, + apiKey: string, +): Promise { + const normBase = base.trim().replace(/\/+$/, ""); + const apps = await fetchRemoteApps(normBase, apiKey.trim()); + const conn: RemoteConnection = { + id: Date.now().toString(36), + name: name.trim() || hostOf(normBase), + base: normBase, + apiKey: apiKey.trim(), + apps, + }; + const list = [...loadConnections().filter((c) => c.base !== normBase), conn]; + persist(list); + registerConnections(list); + return conn; +} + +export function removeConnection(id: string): RemoteConnection[] { + const list = loadConnections().filter((c) => c.id !== id); + persist(list); + registerConnections(list); + return list; +} + +/** Build the full agent-picker list: local apps first, then remote apps. */ +export function buildAgentEntries( + localApps: string[], + conns: RemoteConnection[], +): AgentEntry[] { + const local: AgentEntry[] = localApps.map((app) => ({ + id: app, + label: app, + app, + remote: false, + })); + const remote: AgentEntry[] = conns.flatMap((c) => + c.apps.map((app) => ({ + id: remoteAppId(c.id, app), + label: app, + app, + remote: true, + host: hostOf(c.base), + })), + ); + return [...local, ...remote]; +} diff --git a/frontend/src/adk/search.ts b/frontend/src/adk/search.ts new file mode 100644 index 00000000..2340d346 --- /dev/null +++ b/frontend/src/adk/search.ts @@ -0,0 +1,156 @@ +// Smart search, scoped to a single agent, organized by source. Results carry a +// `type` discriminator so the UI renders each kind differently. +// - session: text match across the agent's session contents (client-side, +// reusing the ADK list/get-session endpoints). +// - web: the agent's web-search tool, run server-side with the user's +// environment credentials (see backend /web/search). +// - knowledge/memory: reserved (TODO, backend-backed). + +import { getSession, listSessions, webSearch, type AdkSession } from "./client"; + +export type SearchSource = "session" | "web" | "knowledge" | "memory"; + +export interface SessionResult { + type: "session"; + appId: string; + sessionId: string; + title: string; + snippet: string; + role: string; + ts?: number; +} + +export interface WebResult { + type: "web"; + index: number; + title: string; + url: string; + siteName: string; + summary: string; +} + +export type SearchResult = SessionResult | WebResult; + +/** Search outcome: results plus an optional human note (e.g. "not mounted"). */ +export interface SearchOutcome { + results: SearchResult[]; + note?: string; +} + +const MAX_RESULTS = 50; +const SNIPPET_PAD = 48; + +function textOf(session: AdkSession): { text: string; role: string; ts?: number }[] { + return (session.events ?? []).flatMap((ev) => { + const parts = ev.content?.parts ?? []; + const text = parts + .map((p) => (typeof p.text === "string" ? p.text : "")) + .filter(Boolean) + .join(""); + return text ? [{ text, role: ev.author ?? ev.content?.role ?? "", ts: ev.timestamp }] : []; + }); +} + +function firstUserText(session: AdkSession): string { + for (const ev of session.events ?? []) { + if (ev.author === "user" || ev.content?.role === "user") { + const t = (ev.content?.parts ?? []).map((p) => p.text).find(Boolean); + if (t) return t; + } + } + return "未命名会话"; +} + +function snippetAround(text: string, idx: number, qlen: number): string { + const start = Math.max(0, idx - SNIPPET_PAD); + const end = Math.min(text.length, idx + qlen + SNIPPET_PAD); + return (start > 0 ? "…" : "") + text.slice(start, end).trim() + (end < text.length ? "…" : ""); +} + +/** Text-match across one agent's sessions. */ +async function searchSessions( + userId: string, + appId: string, + query: string, +): Promise { + const q = query.trim().toLowerCase(); + if (!q || !appId) return []; + + const list = await listSessions(appId, userId); + const hydrated = await Promise.all( + list.map(async (s) => { + if (s.events?.length) return s; + try { + return await getSession(appId, userId, s.id); + } catch { + return s; + } + }), + ); + + const results: SessionResult[] = []; + for (const session of hydrated) { + for (const { text, role, ts } of textOf(session)) { + const idx = text.toLowerCase().indexOf(q); + if (idx === -1) continue; + results.push({ + type: "session", + appId, + sessionId: session.id, + title: firstUserText(session), + snippet: snippetAround(text, idx, q.length), + role, + ts: ts ?? session.lastUpdateTime, + }); + break; // one result per session + } + } + results.sort((a, b) => (b.ts ?? 0) - (a.ts ?? 0)); + return results.slice(0, MAX_RESULTS); +} + +/** Run the agent's web-search tool server-side (env credentials). */ +async function searchWeb(appId: string, query: string): Promise { + if (!appId || !query.trim()) return { results: [] }; + let res; + try { + res = await webSearch(appId, query.trim()); + } catch (e) { + const msg = String(e); + return { + results: [], + note: msg.includes("404") + ? "网页搜索接口未就绪(后端未启用 /web/search)。" + : `网页搜索失败:${msg}`, + }; + } + const { mounted, results, error } = res; + if (!mounted) return { results: [], note: "该 Agent 未挂载 Web Search 工具。" }; + if (error) return { results: [], note: error }; + return { + results: results.map((hit, index) => ({ + type: "web", + index, + title: hit.title, + url: hit.url, + siteName: hit.siteName, + summary: hit.summary, + })), + }; +} + +export interface SearchContext { + userId: string; + appId: string; +} + +/** Dispatch a search to the chosen source. */ +export async function search( + source: SearchSource, + query: string, + ctx: SearchContext, +): Promise { + if (source === "session") return { results: await searchSessions(ctx.userId, ctx.appId, query) }; + if (source === "web") return searchWeb(ctx.appId, query); + return { results: [], note: "该搜索源即将支持。" }; +} diff --git a/frontend/src/assets/volcengine.svg b/frontend/src/assets/volcengine.svg new file mode 100644 index 00000000..ecf6d75e --- /dev/null +++ b/frontend/src/assets/volcengine.svg @@ -0,0 +1 @@ +Volcengine \ No newline at end of file diff --git a/frontend/src/create/CustomCreate.css b/frontend/src/create/CustomCreate.css new file mode 100644 index 00000000..3b11ef83 --- /dev/null +++ b/frontend/src/create/CustomCreate.css @@ -0,0 +1,1127 @@ +/* CustomCreate — 自定义分步引导 wizard. + * Class prefix `cw-` (custom-wizard) to avoid collisions with app styles. + * Uses the shadcn CSS variables from src/styles.css :root. */ + +.cw-root { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + height: 100%; + color: hsl(var(--foreground)); +} + +/* ---------- header ---------- */ +.cw-header { + flex-shrink: 0; + display: flex; + align-items: flex-start; + gap: 16px; + padding: 18px 24px 16px; + border-bottom: 1px solid hsl(var(--border)); +} +.cw-header-title { + flex: 1; + min-width: 0; +} +.cw-header-mode { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11.5px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: hsl(var(--muted-foreground)); +} +.cw-title { + margin: 5px 0 2px; + font-size: 21px; + font-weight: 650; + letter-spacing: -0.02em; +} +.cw-subtitle { + margin: 0; + font-size: 13px; + color: hsl(var(--muted-foreground)); +} +.cw-progress-pill { + flex-shrink: 0; + margin-top: 2px; + padding: 5px 12px; + border-radius: 999px; + background: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground)); + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +/* ---------- body: single scroll container ---------- */ +/* The whole form scrolls here; this element is also the IntersectionObserver + root for the scroll-spy rail. */ +.cw-body { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +/* Centers the [form column + rail] group as one unit with a modest gap. */ +.cw-center { + display: flex; + align-items: flex-start; + justify-content: center; + gap: 32px; + max-width: 960px; + margin: 0 auto; + padding: 32px 24px 80px; +} + +/* ---------- form column (all sections stacked) ---------- */ +.cw-form-col { + flex: 1 1 auto; + min-width: 0; + max-width: 640px; + display: flex; + flex-direction: column; + gap: 44px; +} + +/* ---------- section ---------- */ +.cw-section { + scroll-margin-top: 24px; +} +.cw-sec-head { + margin-bottom: 18px; +} +.cw-sec-title { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 4px; + font-size: 17px; + font-weight: 650; + letter-spacing: -0.01em; + color: hsl(var(--foreground)); +} +.cw-sec-required { + padding: 1px 7px; + border-radius: 999px; + background: hsl(var(--foreground) / 0.08); + color: hsl(var(--muted-foreground)); + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.02em; +} +.cw-sec-hint { + margin: 0; + font-size: 13px; + line-height: 1.5; + color: hsl(var(--muted-foreground)); +} + +/* ---------- finish action (end of review section) ---------- */ +.cw-finish-actions { + display: flex; + justify-content: flex-end; +} +.cw-btn-finish { + padding: 11px 22px; + font-size: 14px; +} + +/* ---------- right rail (scroll-spy indicator, sticky) ---------- */ +.cw-rail { + position: sticky; + top: 0; + align-self: flex-start; + flex-shrink: 0; + width: 208px; + padding: 4px 0; +} +.cw-steps { + position: relative; + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 1px; +} +/* Connector line, anchored to the steps list. Rows are a fixed 46px tall with + centred markers, so every dot centre sits 23px from its row's top — the line + therefore runs exactly from the first dot centre (23px) to the last (bottom + 23px). Dot column centre = step padding-left (8px) + marker half (11px) = 19px. + z-index 0 keeps it BEHIND the dots. */ +.cw-rail-track { + position: absolute; + left: 18px; + top: 23px; + bottom: 23px; + width: 2px; + border-radius: 2px; + background: hsl(var(--border)); + z-index: 0; +} +.cw-rail-fill { + position: absolute; + left: 0; + top: 0; + width: 100%; + border-radius: 2px; + background: hsl(var(--foreground) / 0.55); +} +.cw-step { + position: relative; + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-height: 46px; + padding: 7px 8px; + border: none; + border-radius: 9px; + background: none; + text-align: left; + cursor: pointer; + font: inherit; + transition: background 0.12s; +} +.cw-step:hover { + background: hsl(var(--foreground) / 0.05); +} +.cw-step.is-active { + background: hsl(var(--foreground) / 0.06); +} +/* Fixed-width column keeps the dot centre constant (so the line aligns). */ +.cw-step-marker { + position: relative; + z-index: 2; /* above the connector line */ + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; +} +/* Simple dot: muted by default, solid black when active/done. */ +.cw-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: hsl(var(--foreground) / 0.25); + transition: background 0.15s, width 0.15s, height 0.15s; +} +.cw-step.is-done:not(.is-active) .cw-dot { + background: hsl(var(--foreground) / 0.7); +} +.cw-step.is-active .cw-dot { + width: 11px; + height: 11px; + background: hsl(var(--foreground)); +} +.cw-step-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} +.cw-step-labelrow { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} +.cw-step-required { + flex-shrink: 0; + padding: 1px 6px; + border-radius: 999px; + background: hsl(var(--foreground) / 0.08); + color: hsl(var(--muted-foreground)); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; +} +.cw-step.is-active .cw-step-required { + background: hsl(var(--foreground) / 0.12); + color: hsl(var(--foreground)); +} +.cw-step-label { + font-size: 13px; + font-weight: 500; + color: hsl(var(--muted-foreground)); + transition: color 0.12s; +} +.cw-step.is-active .cw-step-label { + color: hsl(var(--foreground)); + font-weight: 600; +} +.cw-step.is-done:not(.is-active) .cw-step-label { + color: hsl(var(--foreground) / 0.75); +} +.cw-step-hint { + font-size: 11px; + color: hsl(var(--muted-foreground)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +/* ---------- forms ---------- */ +.cw-form { + display: flex; + flex-direction: column; + gap: 20px; +} +.cw-section-desc { + margin: 0; + font-size: 13px; + line-height: 1.6; + color: hsl(var(--muted-foreground)); +} +.cw-field { + display: flex; + flex-direction: column; + gap: 7px; +} +.cw-label { + font-size: 13px; + font-weight: 600; + color: hsl(var(--foreground)); +} +.cw-req { + margin-left: 2px; + color: hsl(var(--destructive)); +} +.cw-help { + font-size: 12px; + color: hsl(var(--muted-foreground)); + line-height: 1.5; +} +.cw-error-text { + font-size: 12px; + color: hsl(var(--destructive)); +} + +.cw-input { + width: 100%; + padding: 10px 12px; + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font: inherit; + font-size: 14px; + transition: border-color 0.12s, box-shadow 0.12s; +} +.cw-input::placeholder { + color: hsl(var(--muted-foreground)); +} +.cw-input:focus { + outline: none; + border-color: hsl(var(--ring) / 0.45); + box-shadow: 0 0 0 3px hsl(var(--ring) / 0.12); +} +.cw-input.is-error { + border-color: hsl(var(--destructive) / 0.6); +} + +.cw-textarea { + width: 100%; + padding: 11px 13px; + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); + background: hsl(var(--background)); + color: hsl(var(--foreground)); + font: inherit; + font-size: 14px; + line-height: 1.6; + resize: vertical; + transition: border-color 0.12s, box-shadow 0.12s; +} +.cw-textarea::placeholder { + color: hsl(var(--muted-foreground)); +} +.cw-textarea:focus { + outline: none; + border-color: hsl(var(--ring) / 0.45); + box-shadow: 0 0 0 3px hsl(var(--ring) / 0.12); +} +.cw-textarea.is-error { + border-color: hsl(var(--destructive) / 0.6); +} +.cw-textarea-sm { + min-height: 80px; +} +.cw-textarea-lg { + min-height: 340px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; +} + +/* ---------- tag editor ---------- */ +.cw-tag-editor { + display: flex; + flex-direction: column; + gap: 12px; +} +.cw-tag-inputrow { + display: flex; + gap: 8px; +} +.cw-tag-inputrow .cw-input { + flex: 1; +} +.cw-presets { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 7px; +} +.cw-presets-label { + font-size: 11.5px; + color: hsl(var(--muted-foreground)); + margin-right: 2px; +} +.cw-chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: 999px; + background: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground)); + font-size: 12.5px; + white-space: nowrap; +} +.cw-chip-ghost { + border: 1px dashed hsl(var(--border)); + background: transparent; + color: hsl(var(--muted-foreground)); + cursor: pointer; + font: inherit; + font-size: 12.5px; + transition: background 0.12s, color 0.12s, border-color 0.12s; +} +.cw-chip-ghost:hover { + background: hsl(var(--accent)); + color: hsl(var(--foreground)); + border-color: hsl(var(--ring) / 0.3); +} +.cw-chip-sub { + background: hsl(var(--primary) / 0.08); + color: hsl(var(--foreground)); +} +.cw-pills { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.cw-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 6px 5px 12px; + border-radius: 999px; + background: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground)); + font-size: 13px; +} +.cw-pill-x { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border: none; + border-radius: 50%; + background: hsl(var(--foreground) / 0.06); + color: hsl(var(--muted-foreground)); + cursor: pointer; + transition: background 0.12s, color 0.12s; +} +.cw-pill-x:hover { + background: hsl(var(--destructive) / 0.12); + color: hsl(var(--destructive)); +} +.cw-empty-line { + margin: 0; + font-size: 12.5px; + color: hsl(var(--muted-foreground)); +} + +/* ---------- checklist (built-in tools / tracing exporters) ---------- */ +.cw-checklist { + display: flex; + flex-direction: column; + gap: 8px; +} +.cw-check { + display: flex; + align-items: flex-start; + gap: 12px; + width: 100%; + padding: 12px 14px; + border: 1px solid hsl(var(--border)); + border-radius: 12px; + background: hsl(var(--background)); + text-align: left; + cursor: pointer; + font: inherit; + transition: background 0.12s, border-color 0.12s; +} +.cw-check:hover { + background: hsl(var(--foreground) / 0.05); +} +.cw-check.is-on { + background: hsl(var(--foreground) / 0.08); + border-color: hsl(var(--foreground) / 0.18); +} +.cw-check-box { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + margin-top: 1px; + border: 1px solid hsl(var(--border)); + border-radius: 5px; + background: hsl(var(--background)); + color: hsl(var(--background)); + transition: background 0.12s, border-color 0.12s, color 0.12s; +} +.cw-check.is-on .cw-check-box { + background: hsl(var(--foreground)); + border-color: hsl(var(--foreground)); + color: hsl(var(--background)); +} +.cw-check-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.cw-check-title { + font-size: 13.5px; + font-weight: 600; + color: hsl(var(--foreground)); +} +.cw-check-desc { + font-size: 12px; + line-height: 1.5; + color: hsl(var(--muted-foreground)); +} + +/* ---------- segmented backend picker ---------- */ +.cw-segmented { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.cw-seg { + flex: 1 1 160px; + display: flex; + flex-direction: column; + gap: 2px; + padding: 11px 13px; + border: 1px solid hsl(var(--border)); + border-radius: 11px; + background: hsl(var(--background)); + text-align: left; + cursor: pointer; + font: inherit; + transition: background 0.12s, border-color 0.12s; +} +.cw-seg:hover { + background: hsl(var(--foreground) / 0.05); +} +.cw-seg.is-on { + background: hsl(var(--foreground) / 0.08); + border-color: hsl(var(--foreground) / 0.18); +} +.cw-seg-title { + font-size: 13px; + font-weight: 600; + color: hsl(var(--foreground)); +} +.cw-seg-desc { + font-size: 11.5px; + line-height: 1.45; + color: hsl(var(--muted-foreground)); +} + +/* ---------- custom function-tool editor ---------- */ +.cw-ctool { + display: flex; + flex-direction: column; + gap: 12px; +} +.cw-ctool-inputs { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.cw-ctool-inputs .cw-input { + flex: 1 1 180px; +} +.cw-ctool-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.cw-ctool-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid hsl(var(--border)); + border-radius: 11px; + background: hsl(var(--card)); +} +.cw-ctool-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 8px; + background: hsl(var(--secondary)); + color: hsl(var(--muted-foreground)); +} +.cw-ctool-meta { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.cw-ctool-name { + font-size: 13.5px; + font-weight: 600; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + color: hsl(var(--foreground)); + word-break: break-word; +} +.cw-ctool-desc { + font-size: 12px; + line-height: 1.5; + color: hsl(var(--muted-foreground)); +} + +/* ---------- MCP tool editor ---------- */ +.cw-mcp { + display: flex; + flex-direction: column; + gap: 12px; +} +.cw-mcp-list { + display: flex; + flex-direction: column; + gap: 12px; +} +.cw-mcp-row { + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px; + border: 1px solid hsl(var(--border)); + border-radius: 12px; + background: hsl(var(--card)); +} +.cw-mcp-rowhead { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.cw-mcp-transport { + display: inline-flex; + gap: 6px; +} +.cw-seg-sm { + flex: 0 0 auto; + min-width: 72px; + flex-direction: row; + align-items: center; + justify-content: center; + text-align: center; + padding: 6px 16px; + border-radius: 9px; +} +.cw-seg-sm .cw-seg-title { + font-size: 12.5px; +} +.cw-mcp .cw-add-sub { + margin-top: 0; +} + +/* ---------- conditional sub-fields under a toggle ---------- */ +.cw-subfield { + margin: -2px 0 2px; + padding: 14px 16px; + border: 1px solid hsl(var(--border)); + border-radius: 12px; + background: hsl(var(--foreground) / 0.02); +} + +/* ---------- toggles ---------- */ +.cw-toggle-stack { + gap: 14px; +} +.cw-toggle { + display: flex; + align-items: center; + gap: 14px; + width: 100%; + padding: 16px 18px; + border: 1px solid hsl(var(--border)); + border-radius: 14px; + background: hsl(var(--card)); + text-align: left; + cursor: pointer; + font: inherit; + transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; +} +.cw-toggle:hover { + border-color: hsl(var(--ring) / 0.25); +} +.cw-toggle.is-on { + border-color: hsl(var(--primary) / 0.4); + box-shadow: 0 1px 2px hsl(var(--foreground) / 0.04); +} +.cw-toggle-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border-radius: 11px; + background: hsl(var(--secondary)); + color: hsl(var(--muted-foreground)); + transition: background 0.15s, color 0.15s; +} +.cw-toggle.is-on .cw-toggle-icon { + background: hsl(var(--primary) / 0.1); + color: hsl(var(--primary)); +} +.cw-toggle-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} +.cw-toggle-title { + font-size: 14px; + font-weight: 600; +} +.cw-toggle-desc { + font-size: 12.5px; + line-height: 1.5; + color: hsl(var(--muted-foreground)); +} +.cw-switch { + flex-shrink: 0; + display: flex; + align-items: center; + width: 42px; + height: 24px; + padding: 2px; + border-radius: 999px; + background: hsl(var(--border)); + transition: background 0.18s; +} +.cw-toggle.is-on .cw-switch { + background: hsl(var(--primary)); + justify-content: flex-end; +} +.cw-switch-knob { + display: block; + width: 20px; + height: 20px; + border-radius: 50%; + background: hsl(var(--background)); + box-shadow: 0 1px 2px hsl(var(--foreground) / 0.2); +} + +/* ---------- sub-agents ---------- */ +.cw-sub-list { + display: flex; + flex-direction: column; + gap: 14px; +} +.cw-sub { + display: flex; + flex-direction: column; + gap: 14px; + padding: 16px; + border: 1px solid hsl(var(--border)); + border-radius: 14px; + background: hsl(var(--card)); + box-shadow: 0 1px 2px hsl(var(--foreground) / 0.03); +} +.cw-sub-head { + display: flex; + align-items: center; + justify-content: space-between; +} +.cw-sub-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + background: hsl(var(--primary) / 0.08); + color: hsl(var(--foreground)); + font-size: 12.5px; + font-weight: 600; +} +.cw-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: none; + border-radius: 8px; + background: none; + color: hsl(var(--muted-foreground)); + cursor: pointer; + transition: background 0.12s, color 0.12s; +} +.cw-icon-danger:hover { + background: hsl(var(--destructive) / 0.1); + color: hsl(var(--destructive)); +} +.cw-add-sub { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + margin-top: 14px; + padding: 12px; + width: 100%; + border: 1px dashed hsl(var(--border)); + border-radius: 12px; + background: transparent; + color: hsl(var(--muted-foreground)); + font: inherit; + font-size: 13.5px; + font-weight: 500; + cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s; +} +.cw-add-sub:hover { + background: hsl(var(--accent)); + color: hsl(var(--foreground)); + border-color: hsl(var(--ring) / 0.3); +} + +/* ---------- review ---------- */ +.cw-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 11px 14px; + border-radius: var(--radius); + background: hsl(var(--destructive) / 0.08); + color: hsl(var(--destructive)); + font-size: 13px; + line-height: 1.5; +} +.cw-review { + display: flex; + flex-direction: column; + border: 1px solid hsl(var(--border)); + border-radius: 14px; + overflow: hidden; + background: hsl(var(--card)); +} +.cw-review-row { + display: flex; + gap: 18px; + padding: 13px 16px; + border-bottom: 1px solid hsl(var(--border)); +} +.cw-review-row:last-child { + border-bottom: none; +} +.cw-review-key { + flex-shrink: 0; + width: 110px; + font-size: 13px; + font-weight: 500; + color: hsl(var(--muted-foreground)); +} +.cw-review-val { + flex: 1; + min-width: 0; + font-size: 13.5px; +} +.cw-review-strong { + font-weight: 600; +} +.cw-review-muted { + color: hsl(var(--muted-foreground)); +} +.cw-review-pre { + margin: 0; + padding: 10px 12px; + background: hsl(var(--muted)); + border-radius: 8px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow-y: auto; +} +.cw-review-chips, +.cw-review-subs { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.cw-tag { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; +} +.cw-tag-on { + background: hsl(142 70% 45% / 0.14); + color: hsl(142 64% 30%); +} +.cw-tag-off { + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); +} + +/* ---------- buttons ---------- */ +.cw-btn { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 9px 16px; + border-radius: 10px; + border: 1px solid transparent; + font: inherit; + font-size: 13.5px; + font-weight: 550; + cursor: pointer; + transition: background 0.13s, opacity 0.13s, border-color 0.13s, transform 0.1s; +} +.cw-btn:active { + transform: scale(0.98); +} +.cw-btn-primary { + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); +} +.cw-btn-primary:hover:not(:disabled) { + opacity: 0.88; +} +.cw-btn-primary:disabled { + opacity: 0.4; + cursor: default; +} +.cw-btn-ghost { + background: hsl(var(--background)); + border-color: hsl(var(--border)); + color: hsl(var(--foreground)); +} +.cw-btn-ghost:hover { + background: hsl(var(--accent)); +} +.cw-btn-soft { + background: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground)); + flex-shrink: 0; +} +.cw-btn-soft:hover:not(:disabled) { + background: hsl(var(--accent)); +} +.cw-btn-soft:disabled { + opacity: 0.45; + cursor: default; +} + +/* ---------- icons ---------- */ +.cw-i { + width: 16px; + height: 16px; + flex-shrink: 0; +} +.cw-i-sm { + width: 14px; + height: 14px; +} + +/* ---------- finish → project preview ---------- */ +.cw-root-preview { + height: 100%; +} +.cw-preview-bar { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 14px; + padding: 12px 24px; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--panel)); +} +.cw-preview-title { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 13.5px; + font-weight: 600; + color: hsl(var(--foreground)); +} +.cw-preview-body { + flex: 1; + min-height: 0; + display: flex; +} +.cw-preview-body > * { + flex: 1; + min-height: 0; +} + +/* ---------- skill hub picker ---------- */ +.cw-skillhub { + display: flex; + flex-direction: column; + gap: 14px; +} +.cw-skill-searchrow { + display: flex; + gap: 8px; +} +.cw-skill-searchbox { + position: relative; + flex: 1; + min-width: 0; + display: flex; + align-items: center; +} +.cw-skill-searchicon { + position: absolute; + left: 11px; + color: hsl(var(--muted-foreground)); + pointer-events: none; +} +.cw-skill-input { + padding-left: 36px; +} +.cw-skill-selected { + display: flex; + flex-direction: column; + gap: 8px; +} +.cw-skill-selected-label { + font-size: 11.5px; + font-weight: 600; + color: hsl(var(--muted-foreground)); +} +.cw-skill-results { + display: flex; + flex-direction: column; + gap: 8px; +} +.cw-skill-result { + display: flex; + align-items: flex-start; + gap: 12px; + width: 100%; + padding: 12px 14px; + border: 1px solid hsl(var(--border)); + border-radius: 12px; + background: hsl(var(--background)); + text-align: left; + cursor: pointer; + font: inherit; + transition: background 0.12s, border-color 0.12s; +} +.cw-skill-result:hover { + background: hsl(var(--foreground) / 0.05); +} +.cw-skill-result.is-on { + background: hsl(var(--foreground) / 0.08); + border-color: hsl(var(--foreground) / 0.18); +} +.cw-skill-result-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + margin-top: 1px; + border: 1px solid hsl(var(--border)); + border-radius: 7px; + background: hsl(var(--background)); + color: hsl(var(--muted-foreground)); + transition: background 0.12s, border-color 0.12s, color 0.12s; +} +.cw-skill-result.is-on .cw-skill-result-icon { + background: hsl(var(--foreground)); + border-color: hsl(var(--foreground)); + color: hsl(var(--background)); +} +.cw-skill-result-meta { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} +.cw-skill-result-name { + font-size: 13.5px; + font-weight: 600; + color: hsl(var(--foreground)); + word-break: break-word; +} +.cw-skill-result-desc { + font-size: 12px; + line-height: 1.5; + color: hsl(var(--muted-foreground)); +} +.cw-skill-result-repo { + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + color: hsl(var(--muted-foreground)); + word-break: break-all; +} + +/* ---------- spinner ---------- */ +.cw-spin { + animation: cw-spin 0.8s linear infinite; +} +@keyframes cw-spin { + to { + transform: rotate(360deg); + } +} + +/* ---------- responsive ---------- */ +@media (max-width: 860px) { + /* Drop the rail; the form is the single source of navigation by scrolling. */ + .cw-rail { + display: none; + } + .cw-center { + gap: 0; + padding: 24px 16px 64px; + } + .cw-form-col { + max-width: 100%; + } +} diff --git a/frontend/src/create/CustomCreate.tsx b/frontend/src/create/CustomCreate.tsx new file mode 100644 index 00000000..86c72f35 --- /dev/null +++ b/frontend/src/create/CustomCreate.tsx @@ -0,0 +1,1660 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import { + ArrowLeft, + Bot, + Boxes, + Check, + Cpu, + Database, + Eye, + FileDown, + Info, + LayoutGrid, + Layers, + Loader2, + Plus, + Rocket, + Search, + Sparkles, + Trash2, + Wrench, + X, +} from "lucide-react"; +import { + type CreateModeProps, + type AgentDraft, + type CustomTool, + type McpTool, + type SelectedSkill, + emptyDraft, +} from "./types"; +import { + BUILTIN_TOOLS, + STM_BACKENDS, + LTM_BACKENDS, + KB_BACKENDS, + TRACING_EXPORTERS, + type BackendOption, +} from "./veadkCatalog"; +import { generateProject } from "./codegen"; +import { draftToYaml } from "./configYaml"; +import { searchSkills, downloadSkillFiles, type SkillHit } from "./skills"; +import type { AgentProject } from "./project"; +import { ProjectPreview } from "../ui/ProjectPreview"; +import "./CustomCreate.css"; + +/** Trigger a browser download of a text file. */ +function downloadText(filename: string, text: string, mime = "text/plain") { + const url = URL.createObjectURL(new Blob([text], { type: `${mime};charset=utf-8` })); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +/* ---------------------------------------------------------------- * + * Step metadata. Each step renders its own form panel on the right; + * the left rail shows progress + per-step completion checkmarks. + * ---------------------------------------------------------------- */ +type StepId = + | "basic" + | "model" + | "tools" + | "skills" + | "memory" + | "knowledge" + | "tracing" + | "subagents" + | "review"; + +interface StepMeta { + id: StepId; + label: string; + hint: string; + icon: typeof Bot; + required?: boolean; +} + +const STEPS: StepMeta[] = [ + { id: "basic", label: "基本信息", hint: "名称、描述与系统提示词", icon: Info, required: true }, + { id: "model", label: "模型配置", hint: "模型与服务(可选)", icon: Cpu }, + { id: "tools", label: "工具", hint: "可调用的能力", icon: Wrench }, + { id: "skills", label: "技能", hint: "声明式技能", icon: Sparkles }, + { id: "memory", label: "记忆", hint: "短期 / 长期", icon: Layers }, + { id: "knowledge", label: "知识库", hint: "外部知识检索", icon: Database }, + { id: "tracing", label: "观测", hint: "Tracing 与 A2UI", icon: Eye }, + { id: "subagents", label: "子 Agent", hint: "嵌套协作", icon: Boxes }, + { id: "review", label: "完成", hint: "预览并创建", icon: Rocket }, +]; + +const TOOL_PRESETS = [ + "web_search", + "image_generate", + "code_runner", + "calculator", + "file_reader", +]; + +/* ---------------------------------------------------------------- * + * A small reusable "add many strings" editor (used by skills + the + * sub-agent tool lists). Free-text input + preset chips + a list of + * removable pills. + * ---------------------------------------------------------------- */ +function TagEditor({ + values, + onChange, + placeholder, + presets, +}: { + values: string[]; + onChange: (next: string[]) => void; + placeholder: string; + presets?: string[]; +}) { + const [text, setText] = useState(""); + + const add = (raw: string) => { + const v = raw.trim(); + if (!v || values.includes(v)) { + setText(""); + return; + } + onChange([...values, v]); + setText(""); + }; + + const remove = (v: string) => onChange(values.filter((x) => x !== v)); + + return ( +
+
+ setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + add(text); + } + }} + /> + +
+ + {presets && presets.length > 0 && ( +
+ 推荐 + {presets + .filter((p) => !values.includes(p)) + .map((p) => ( + + ))} +
+ )} + + {values.length > 0 ? ( +
+ + {values.map((v) => ( + + {v} + + + ))} + +
+ ) : ( +

暂未添加,回车或点击「添加」即可加入。

+ )} +
+ ); +} + +/* ---------------------------------------------------------------- * + * Multi-select checklist. Each row = label + desc, toggling the id in + * `selected`. Used for built-in tools and tracing exporters. + * ---------------------------------------------------------------- */ +interface ChecklistItem { + id: string; + label: string; + desc: string; +} + +function Checklist({ + items, + selected, + onToggle, +}: { + items: ChecklistItem[]; + selected: string[]; + onToggle: (id: string) => void; +}) { + return ( +
+ {items.map((it) => { + const on = selected.includes(it.id); + return ( + + ); + })} +
+ ); +} + +/* ---------------------------------------------------------------- * + * Segmented backend picker. Renders BackendOption[] as a wrapping row + * of selectable cards; one active at a time. + * ---------------------------------------------------------------- */ +function BackendSelect({ + options, + value, + onChange, +}: { + options: BackendOption[]; + value: string | undefined; + onChange: (id: string) => void; +}) { + return ( +
+ {options.map((o) => { + const on = (value ?? options[0]?.id) === o.id; + return ( + + ); + })} +
+ ); +} + +/* ---------------------------------------------------------------- * + * Custom function-tool editor: add {name, description} rows. Name is + * required; description optional. Rows are removable. + * ---------------------------------------------------------------- */ +function CustomToolEditor({ + tools, + onChange, +}: { + tools: CustomTool[]; + onChange: (next: CustomTool[]) => void; +}) { + const [name, setName] = useState(""); + const [desc, setDesc] = useState(""); + + const add = () => { + const n = name.trim(); + if (!n) return; + onChange([...tools, { name: n, description: desc.trim() }]); + setName(""); + setDesc(""); + }; + + const remove = (i: number) => onChange(tools.filter((_, idx) => idx !== i)); + + return ( +
+
+ setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + add(); + } + }} + /> + setDesc(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + add(); + } + }} + /> + +
+ + {tools.length > 0 ? ( +
+ + {tools.map((t, i) => ( + + + + + + {t.name} + {t.description && ( + {t.description} + )} + + + + ))} + +
+ ) : ( +

暂无自定义函数工具,生成时会为每个工具创建可运行的桩函数。

+ )} +
+ ); +} + +/* ---------------------------------------------------------------- * + * MCP tool editor: edits draft.mcpTools. Each row picks a transport + * (http / stdio) and shows the matching fields. http -> url + optional + * bearer token; stdio -> command + space-separated args. Optional name. + * ---------------------------------------------------------------- */ +function McpToolEditor({ + tools, + onChange, +}: { + tools: McpTool[]; + onChange: (next: McpTool[]) => void; +}) { + const update = (i: number, p: Partial) => + onChange(tools.map((t, idx) => (idx === i ? { ...t, ...p } : t))); + + const remove = (i: number) => onChange(tools.filter((_, idx) => idx !== i)); + + const add = () => + onChange([...tools, { name: "", transport: "http", url: "" }]); + + return ( +
+ {tools.length > 0 && ( +
+ + {tools.map((t, i) => ( + +
+
+ + +
+ +
+ + update(i, { name: e.target.value })} + /> + + {t.transport === "http" ? ( + <> + update(i, { url: e.target.value })} + /> + update(i, { authToken: e.target.value })} + /> + + ) : ( + <> + update(i, { command: e.target.value })} + /> + + update(i, { + args: e.target.value.split(/\s+/).filter(Boolean), + }) + } + /> + + )} +
+ ))} +
+
+ )} + + + + {tools.length === 0 && ( +

+ 暂无 MCP 工具,点击「添加 MCP 工具」连接外部 MCP 服务。 +

+ )} +
+ ); +} + +/* ---------------------------------------------------------------- * + * Skill Hub search + select. Searches the skill hub (skills.ts) and + * lets the user toggle results into draft.selectedSkills (de-duped by + * slug). Selected skills show as removable rows above the results. + * ---------------------------------------------------------------- */ +function SkillHubPicker({ + selected, + onChange, +}: { + selected: SelectedSkill[]; + onChange: (next: SelectedSkill[]) => void; +}) { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [searched, setSearched] = useState(false); + + const isSelected = (slug: string) => selected.some((s) => s.slug === slug); + + const toggle = (hit: SkillHit) => { + if (isSelected(hit.slug)) { + onChange(selected.filter((s) => s.slug !== hit.slug)); + } else { + onChange([ + ...selected, + { slug: hit.slug, name: hit.name, namespace: hit.namespace }, + ]); + } + }; + + const removeSelected = (slug: string) => + onChange(selected.filter((s) => s.slug !== slug)); + + const runSearch = async (q: string) => { + setLoading(true); + setError(null); + setSearched(true); + try { + const hits = await searchSkills(q); + setResults(hits); + } catch (e) { + setError(e instanceof Error ? e.message : "搜索失败,请稍后重试。"); + setResults([]); + } finally { + setLoading(false); + } + }; + + // Debounce typing ~300ms; also searches on Enter / button via runSearch. + useEffect(() => { + const q = query.trim(); + if (!q) { + setResults([]); + setSearched(false); + setError(null); + return; + } + const t = setTimeout(() => runSearch(q), 300); + return () => clearTimeout(t); + }, [query]); + + return ( +
+
+
+ + setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (query.trim()) runSearch(query); + } + }} + /> +
+ +
+ + {selected.length > 0 && ( +
+ 已选技能 +
+ + {selected.map((s) => ( + + + {s.name} + + + ))} + +
+
+ )} + + {error && ( +
+ + {error} +
+ )} + + {loading && results.length === 0 ? ( +

正在搜索…

+ ) : results.length > 0 ? ( +
+ {results.map((hit) => { + const on = isSelected(hit.slug); + return ( + + ); + })} +
+ ) : searched && !error ? ( +

没有找到匹配的技能,换个关键词试试。

+ ) : ( + !searched && ( +

+ 输入关键词以搜索 Skill Hub,所选技能会在生成项目时下载到 skills/ 目录。 +

+ ) + )} +
+ ); +} + +/* ---------------------------------------------------------------- * + * Toggle switch row. + * ---------------------------------------------------------------- */ +function Toggle({ + checked, + onChange, + title, + desc, + icon: Icon, +}: { + checked: boolean; + onChange: (v: boolean) => void; + title: string; + desc: string; + icon: typeof Bot; +}) { + return ( + + ); +} + +/* ---------------------------------------------------------------- * + * A compact inline editor for one sub-agent. Recursive-friendly: it + * edits the same core AgentDraft fields, so it could nest deeper. + * ---------------------------------------------------------------- */ +function SubAgentEditor({ + draft, + index, + onChange, + onRemove, +}: { + draft: AgentDraft; + index: number; + onChange: (next: AgentDraft) => void; + onRemove: () => void; +}) { + const patch = (p: Partial) => onChange({ ...draft, ...p }); + + return ( + +
+ + + 子 Agent {index + 1} + + +
+ +
+ + patch({ name: e.target.value })} + /> +
+ +
+ + patch({ description: e.target.value })} + /> +
+ +
+ +