diff --git a/offline-backend-20260512/backend/app/gateway/auth_middleware.py b/offline-backend-20260512/backend/app/gateway/auth_middleware.py index e5394c70..ff3bb86c 100644 --- a/offline-backend-20260512/backend/app/gateway/auth_middleware.py +++ b/offline-backend-20260512/backend/app/gateway/auth_middleware.py @@ -47,6 +47,8 @@ _PUBLIC_EXACT_PATHS: frozenset[str] = frozenset( "/api/v1/auth/logout", "/api/v1/auth/setup-status", "/api/v1/auth/initialize", + # 并行多智能体面板专用的「绕过常规登录」取 token 端点(内网部署限制了标准登录时用)。 + "/api/parallel-agents/auth/token", } ) diff --git a/offline-backend-20260512/backend/app/gateway/csrf_middleware.py b/offline-backend-20260512/backend/app/gateway/csrf_middleware.py index b471e207..07b46047 100644 --- a/offline-backend-20260512/backend/app/gateway/csrf_middleware.py +++ b/offline-backend-20260512/backend/app/gateway/csrf_middleware.py @@ -59,6 +59,8 @@ _AUTH_EXEMPT_PATHS: frozenset[str] = frozenset( "/api/v1/auth/logout", "/api/v1/auth/register", "/api/v1/auth/initialize", + # 并行多智能体面板专用取 token 端点(公共 + CSRF 豁免)。 + "/api/parallel-agents/auth/token", } ) diff --git a/offline-backend-20260512/backend/app/gateway/routers/parallel_agents.py b/offline-backend-20260512/backend/app/gateway/routers/parallel_agents.py index f16791c1..be57f4cd 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/parallel_agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/parallel_agents.py @@ -1,14 +1,16 @@ """Server-side parallel fan-out for the standalone parallel multi-agent panel. The Vue embeddable panel sends a single request — ``{task_name, brief, agent_ids}`` -— and this endpoint **fans out** to every agent in parallel (file writes disabled), -then **merges** their LangGraph streams into ONE SSE tagged per ``agent_id``, so the -client only consumes one stream and never parses LangGraph internals. +— and this endpoint **fans out** to every agent in parallel, then **merges** their +LangGraph streams into ONE SSE tagged per ``agent_id``, so the client only consumes +one stream and never parses LangGraph internals. -It reuses the roundtable single-agent machinery from ``multi_agent``: -``_special_run(..., no_file=True)`` runs one seat under the ``seat_parallel`` run -policy (which excludes ``write_file`` / ``str_replace`` / ``bash`` / ``web_search`` -/ ``agent_orchestration`` …), so parallel agents can't write files or dispatch. +Each fan-out agent runs as a pure **executing seat**: ``excluded_tools`` = +``_EXCLUDED_TOOLS`` (``write_file`` / ``str_replace`` / ``agent_orchestration`` / +``bash``) so a seat can neither write files (parallel writes would clash) nor +dispatch / run scripts (a weak model fed "总控 / 业务链条" framing would otherwise +mimic the controller and "派活"). Read-only tools + ``ask_clarification`` stay on, +so the step bar and clarification cards still work. Unified event protocol (one JSON object per ``data:`` frame):: @@ -31,18 +33,24 @@ import json import logging from typing import Any, AsyncIterator +import httpx from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field from app.gateway.routers.multi_agent import ( _ROUNDTABLE_THREAD_METADATA, _auth_headers, _create_thread, + _flatten_content, _loopback_base, _make_loopback_client, - _special_run, + _parse_last_messages, + _run_payload, + _stream_upstream, _validate_agent_id, ) +from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/parallel-agents", tags=["parallel-agents"]) @@ -50,6 +58,49 @@ router = APIRouter(prefix="/api/parallel-agents", tags=["parallel-agents"]) # Soft cap on parallel degree — protects remote model concurrency + sandbox. MAX_PARALLEL_AGENTS = 8 +# 禁两类工具:①**写文件**(write_file / str_replace)—— 并行席位互相写文件会打架; +# ②**派活 / 脚本逃逸**(agent_orchestration / bash)—— 本面板里每个智能体只做自己那 +# 份分析交付,绝不该扮演「总控」去调度别人。弱模型(deepseek 等)拿到带「总控 / 业务 +# 链条」语气的任务后,会概率性地用 agent_orchestration 真去派活,或绕道 bash 跑脚本 +# 调内部接口模仿总控调度(bug:单步单智能体链条里子智能体输出「总控调度指令…」并用 +# glob / bash 翻找派活脚本)。物理禁掉这两类即可根治。其余只读工具(ls / read_file / +# 检索)与 ask_clarification(协作卡片)保留:既不互相打架,又能正常出步骤条 / 要澄清。 +_EXCLUDED_TOOLS: list[str] = [ + "write_file", # 并行不互相写文件打架 + "str_replace", + "agent_orchestration", # 子智能体不派活 / 不调度(防模仿总控) + "bash", # 禁跑脚本(防绕道脚本调内部接口模仿派活) +] + + +# ── 专用「绕过常规登录」取 token ────────────────────────────────────────────── +# 内网部署对标准 /api/v1/auth/login/username 有额外操作限制(口令门 / 网关裁剪等), +# 嵌入方拿不到 token。这里给并行多智能体面板单开一个**公共**端点:传用户名直接签发 token +# (账号不存在则自动注册,与 username 登录同一套逻辑)。已在 auth_middleware / csrf_middleware +# 的白名单里放行(公共 + CSRF 豁免)。⚠️ 这是有意的鉴权旁路,仅供内网部署的本功能使用。 + + +class _TokenRequest(BaseModel): + username: str = Field(min_length=1, max_length=128) + + +class _TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user_id: str + + +@router.post("/auth/token", response_model=_TokenResponse) +async def issue_token(body: _TokenRequest) -> _TokenResponse: + """传用户名 → 直接签发 access_token(绕过常规登录,公共端点)。""" + # 懒加载,避免与 auth 路由的模块级循环依赖。 + from app.gateway.auth import create_access_token + from app.gateway.routers.auth import _get_or_create_user_by_username + + user, _created = await _get_or_create_user_by_username(body.username.strip()) + token = create_access_token(str(user.id), token_version=user.token_version) + return _TokenResponse(access_token=token, token_type="bearer", user_id=str(user.id)) + # ── frame normalisation (mirrors multi-agent.ts) ───────────────────────────── @@ -75,33 +126,136 @@ def _extract_ai_text(chunk: dict[str, Any]) -> str: return "" -def _extract_tool_steps(chunk: dict[str, Any]) -> list[dict[str, Any]]: - """Tool-call steps from a chunk obj — both complete ``tool_calls`` and - incremental ``tool_call_chunks`` (we only need the name to surface a step).""" - steps: list[dict[str, Any]] = [] - tool_calls = chunk.get("tool_calls") - if isinstance(tool_calls, list): - for tc in tool_calls: - if not isinstance(tc, dict): - continue - name = tc.get("name") - if not isinstance(name, str) or not name: - continue - ident = tc.get("id") or name - args = tc.get("args") if isinstance(tc.get("args"), dict) else None - steps.append({"key": f"tool:{ident}", "label": f"调用工具 {name}", "args": args}) - tcc = chunk.get("tool_call_chunks") - if isinstance(tcc, list): - for c in tcc: - if not isinstance(c, dict): - continue - name = c.get("name") - if not isinstance(name, str) or not name: - continue - idx = c.get("index") if isinstance(c.get("index"), int) else 0 - ident = c.get("id") or f"{name}:{idx}" - steps.append({"key": f"tool:{ident}", "label": f"调用工具 {name}", "args": None}) - return steps +def _ingest_tool_chunks(chunks: Any, acc: dict[int, dict[str, Any]]) -> None: + """累积 ``tool_call_chunks`` 的增量(name + args 字符串),按 index 归并 + (mirror multi-agent.ts ``ingestToolCallChunks``)。""" + if not isinstance(chunks, list): + return + for c in chunks: + if not isinstance(c, dict): + continue + idx = c.get("index") if isinstance(c.get("index"), int) else 0 + entry = acc.setdefault(idx, {"id": None, "name": "", "argsStr": ""}) + cid = c.get("id") + if isinstance(cid, str) and cid: + if entry["id"] and entry["id"] != cid: # 同 index 换了新调用 → 重置 + entry["name"] = "" + entry["argsStr"] = "" + entry["id"] = cid + name = c.get("name") + if isinstance(name, str) and name: + entry["name"] = name + a = c.get("args") + if isinstance(a, str): + entry["argsStr"] += a + + +def _synthetic_tool_calls(acc: dict[int, dict[str, Any]]) -> list[dict[str, Any]]: + """把累积的 argsStr 尽力 parse 成完整 args,产出 ``{id,name,args}``(未成完整 JSON 则跳过)。""" + out: list[dict[str, Any]] = [] + for entry in acc.values(): + if not entry["name"]: + continue + s = str(entry["argsStr"]).strip() + if not s: + continue + try: + args = json.loads(s) + except Exception: + continue + if isinstance(args, dict): + out.append({"id": entry["id"], "name": entry["name"], "args": args}) + return out + + +def _clarification_from_messages(messages: list[dict[str, Any]]) -> dict[str, Any] | None: + """从最终 messages 里找最后一个 ``ask_clarification`` 工具调用,抽出结构化澄清载荷 + (字段对齐 multi_agent leader 的 clarification 帧)。""" + for msg in reversed(messages): + if not isinstance(msg, dict): + continue + if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"): + continue + for tc in msg.get("tool_calls") or []: + if isinstance(tc, dict) and (tc.get("name") or "").lower() == "ask_clarification": + cargs = tc.get("args") if isinstance(tc.get("args"), dict) else {} + return { + "question": str(cargs.get("question") or ""), + "clarification_type": str(cargs.get("clarification_type") or ""), + "clarification_context": str(cargs.get("context") or ""), + "options": cargs.get("options") or [], + "allow_custom": bool(cargs.get("allow_custom", True)), + "allow_multiple": resolve_allow_multiple(cargs), + } + return None + + +async def _run_one_agent( + client: httpx.AsyncClient, + base: str, + headers: dict[str, str], + agent_id: str, + thread_id: str, + new_message: str, + model_name: str, +) -> AsyncIterator[str | dict[str, Any]]: + """跑一个智能体一轮(禁写文件 + 禁派活 / 脚本,其余只读工具放开)。 + + 与 ``multi_agent._special_run`` 同形(yield 原始上游 SSE 行 + 末尾一条终态 dict), + 但 ``excluded_tools`` = ``_EXCLUDED_TOOLS``(write_file / str_replace / + agent_orchestration / bash),且不做 post-broadcast(独立面板各智能体互不广播)。 + """ + body = _run_payload( + agent_name=agent_id, + model_name=model_name, + thread_id=thread_id, + new_message=new_message, + excluded_tools=list(_EXCLUDED_TOOLS), + skill_stop_names=[], + thinking_enabled=True, + reasoning_effort="medium", + multitask_strategy="rollback", + ) + captured: list[str] = [] + async for line, done in _stream_upstream(client, base, headers, thread_id, body): + if line is not None: + yield line + elif done is not None: + captured = done + messages = _parse_last_messages(captured) + if messages is None: + # 流被抢占/截断,没有终态 values 帧 —— 静默收尾(调用方据无终态帧兜底)。 + return + last_ai: dict[str, Any] = {} + for msg in reversed(messages): + if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"): + last_ai = msg + break + content = _flatten_content(last_ai.get("content", "")) + # 收集**所有** AI 消息的 tool_calls(全量 args,按顺序)—— 用于在收口时补发所有步骤 + # (含 ls / skill_list 这类无参/中间步骤),保证步骤条不丢步、且 args 完整。 + final_tool_calls: list[dict[str, Any]] = [] + for msg in messages: + if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"): + for tc in msg.get("tool_calls") or []: + if isinstance(tc, dict): + final_tool_calls.append(tc) + clarification = _clarification_from_messages(messages) + if clarification is not None: + # 智能体在向用户要澄清 —— 交给前端渲染「协作卡片」(对齐圆桌第一步)。 + yield { + "type": "clarification", + "content": content, + "clarification": clarification, + "tool_calls": final_tool_calls, + } + else: + yield { + "status": f"子智能体 {agent_id} 完成", + "content": content, + "agent_name": agent_id, + "tool_calls": final_tool_calls, + } def _build_task_message(task_name: str, brief: str) -> str: @@ -112,8 +266,15 @@ def _build_task_message(task_name: str, brief: str) -> str: parts.append(f"任务简介:\n{brief.strip()}") parts.append( "请你从你的专业职责出发,针对以上任务给出你的分析与交付。" - "本轮为**并行**研讨:请把你的产出**直接写在回复正文里**," - "不要写文件、不要执行脚本/命令。" + "本轮为**并行**研讨:请把你的产出**直接写在回复正文里**(本轮禁止写文件)。" + ) + # 角色边界护栏:本面板里每个智能体都是**执行席位**,不是总控 / 指挥。即便任务里带有 + # 「总控 / 业务链条 / 调度 / 派活」字样,也只完成属于自己的那份交付,严禁模仿总控派活。 + parts.append( + "【角色边界】你是本轮的**执行席位**,不是总控 / 指挥。即使任务里出现「总控 / 业务链条 / 调度 / " + "派活」等字样,你也只需完成**属于你自己**的那份分析与交付;严禁模仿总控口吻输出「总控调度指令 / " + "调度对象 / 分配给『…』的任务 / agent_name」之类调度内容,严禁罗列或转派其他席位的任务,严禁调用" + "任何派活 / 调度类工具,也不要用脚本(bash / python)去调用内部接口或翻找派活脚本。" ) return "\n\n".join(parts) @@ -156,28 +317,60 @@ async def run_parallel_agents_stream(request: Request, payload: dict = Body(...) ) -> None: await queue.put({"type": "agent_start", "agent_id": agent_id}) last_event = "" - seen_steps: set[str] = set() + emitted_tools: set[str] = set() # 已发过 step 的 tool_call id(去重) + tool_acc: dict[int, dict[str, Any]] = {} # tool_call_chunks 累积 buffer: list[str] = [] done_sent = False + + async def _emit_tool(tc: Any, allow_empty: bool = False) -> None: + """一个 tool_call(含完整 args)→ 一条 step 事件;按 id 去重。 + + ``allow_empty=False``(流式期间):args 为空先不发 —— 流式帧里的 tool_calls 常先给 + 空 ``{}`` 占位、真实 args 走 tool_call_chunks 后到,过早发会把空 args 钉死。等累积出 + 完整 args(synthetic)或终态全量 tool_calls(``allow_empty=True``)再发。 + """ + if not isinstance(tc, dict): + return + name = tc.get("name") + if not isinstance(name, str) or not name: + return + args = tc.get("args") if isinstance(tc.get("args"), dict) else None + if args is None: + return + if not args and not allow_empty: # 空 args 先不发,等 chunks / 终态补全 + return + key_id = str(tc.get("id") or name) + if key_id in emitted_tools: + return + emitted_tools.add(key_id) + await queue.put( + {"type": "step", "agent_id": agent_id, "key": f"tool_calling:{name}", "label": name, "args": args} + ) + try: thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA) - gen = _special_run( - client, - base, - headers, - {agent_id: thread_id}, - agent_id, - new_message, - [], - model_name, - display_name="", - no_file=True, # seat_parallel policy → 禁 write_file/bash/str_replace… + gen = _run_one_agent( # 只禁文件写入,其余工具放开(保留步骤条) + client, base, headers, agent_id, thread_id, new_message, model_name ) async for chunk in gen: if isinstance(chunk, dict): - # 终态帧 {status, content, agent_name} - content = str(chunk.get("content") or "") - await queue.put({"type": "agent_done", "agent_id": agent_id, "content": content}) + # 终态:先用最终 tool_calls(全量 args)补发漏掉的 step,再发收口/澄清。 + for tc in chunk.get("tool_calls") or []: + await _emit_tool(tc, allow_empty=True) + if chunk.get("type") == "clarification": + clar = chunk.get("clarification") or {} + await queue.put( + { + "type": "agent_clarification", + "agent_id": agent_id, + "content": str(chunk.get("content") or ""), + **clar, + } + ) + else: + await queue.put( + {"type": "agent_done", "agent_id": agent_id, "content": str(chunk.get("content") or "")} + ) done_sent = True continue line = chunk.strip() if isinstance(chunk, str) else "" @@ -200,12 +393,12 @@ async def run_parallel_agents_stream(request: Request, payload: dict = Body(...) if text: buffer.append(text) await queue.put({"type": "delta", "agent_id": agent_id, "text": text}) - for step in _extract_tool_steps(obj): - key = step["key"] - if key in seen_steps: - continue - seen_steps.add(key) - await queue.put({"type": "step", "agent_id": agent_id, **step}) + # 完整 tool_calls(部分模型直接给)+ 累积 chunks 后的合成调用,都尝试发 step。 + for tc in obj.get("tool_calls") or []: + await _emit_tool(tc) + _ingest_tool_chunks(obj.get("tool_call_chunks"), tool_acc) + for tc in _synthetic_tool_calls(tool_acc): + await _emit_tool(tc) if not done_sent: # 流被抢占/截断,没收到终态帧 —— 用已累积正文兜底收口本席位。 await queue.put( diff --git a/parallel-agents-vue/docs/00-测试说明.md b/parallel-agents-vue/docs/00-测试说明.md index d762c1e4..426c5ef1 100644 --- a/parallel-agents-vue/docs/00-测试说明.md +++ b/parallel-agents-vue/docs/00-测试说明.md @@ -14,6 +14,7 @@ | [05-嵌入与深色样式.md](./05-嵌入与深色样式.md) | 配置文件接口地址、抽屉/弹窗嵌入、深色自适应、不污染宿主 | | [06-可用智能体清单.md](./06-可用智能体清单.md) | **测试用真实 agent_id 清单(可直接复制粘贴)** + 示例任务 | | [07-使用说明.md](./07-使用说明.md) | **在其它 Vue 项目中引入哪些组件、如何使用**(props/事件/嵌入) | +| [08-开发文档.md](./08-开发文档.md) | **二次开发**:架构/数据流/事件协议/目录职责/后端契约/扩展指南 | ## 测试环境 diff --git a/parallel-agents-vue/docs/07-使用说明.md b/parallel-agents-vue/docs/07-使用说明.md index 3d11b05e..6a5c2e1d 100644 --- a/parallel-agents-vue/docs/07-使用说明.md +++ b/parallel-agents-vue/docs/07-使用说明.md @@ -102,8 +102,11 @@ app.use(ParallelAgentsVue); // 之后任意模板可直接用 +``` +- `agent_id`**必须是后端真实注册的 id**(只写名字无效);可用 id 见 [06-可用智能体清单.md](./06-可用智能体清单.md)。 +- `name`:席位/气泡显示名,可省(省了用 id)。 +- `avatarType`:可选 `blue|purple|emerald|amber|pink|human`;不传按顺序自动轮转配色。 + +### `username`——免密登录(推荐) + +```vue + +``` + +### `baseUrl`——后端地址(不传用 config 默认) + +```vue + +``` + +### `token` / `getToken`——已有登录态时(优先于 username) + +```vue + + + + +``` + +### `model`——指定模型(不传走默认/后端回退;也可在面板右上角下拉切) + +```vue + +``` + +### `dark`——深色(默认 true,自带成色) + +```vue + +``` + +### `taskId`——存储主键 + 回显(见 §七·二) + +```vue + +``` + +### 全量示例(所有参数一起) + +```vue + + + +``` ## 七、事件 -| 事件 | 载荷 | 触发时机 | -| ----------------- | ------------ | --------------------------------------- | -| `saved` | `SavedTask` | 保存成功(新建或更新)后 | -| `update:taskId` | `string` | 当前任务 id 变化(保存 / 加载历史时) | +| 事件 | 载荷 | 触发时机 | +| ----------------- | ------------------ | --------------------------------------- | +| `saved` | `SavedTask` | 保存成功(新建或更新)后 | +| `update:taskId` | `string` | 当前任务 id 变化(保存 / 加载历史时) | +| `completed` | `AgentResult[]` | 一轮并行分析结束时,回传各智能体返回内容 | ```vue ``` +## 七·一、如何获取模型返回的内容 + +每个智能体(模型)返回的正文是 `AgentResult.content`。两种方式,任选其一: + +**A. 事件**:监听 `@completed`,一轮分析结束时自动回传 `AgentResult[]`: + +```vue + +``` + +**B. 方法(模板 ref)**:随时主动取(不必等事件): + +```vue + + + +``` + +`AgentResult = { agent_id, name, content, status }`,`content` 即该智能体模型返回的正文(Markdown)。 +`defineExpose` 还暴露了 `start()` / `cancel()`,可由宿主代替按钮触发开始 / 停止。 + +## 七·二、如何保存 + 回显聊天记录 + +**保存**:面板里点「保存」即把当前整段聊天记录(各智能体气泡 + 步骤条等)存到后端,按用户隔离。 +传 `:task-id` 时**以它为存储主键**做 upsert(已存在→更新;不存在→用该 id 新建);不传则首次保存由后端 +生成 id(通过 `@update:taskId` 回传,下次再保存即更新这条)。 + +**回显(两种方式)**: + +```vue + + +``` + +```ts +// 方式 1 变体:运行中改 taskId(双向绑定),面板会自动切到并回显那条任务 +const taskId = ref(""); // 留空=新任务 +// +taskId.value = "task-001"; // 改了之后面板自动回显 task-001 +``` + +> 方式 2:面板**右上角内置「历史」下拉**——点开选一条历史任务即回显,**无需写任何代码**。 + +要点:先**保存过**、再用**同一个 taskId**(或从历史里选)才会有内容回显;没存过的新 id 第一次进去是空白 +(后台会探测一次 `GET /…/{id}` 返回 404,属正常,面板静默处理)。 + --- ## 八、交互要点(与圆桌 Step2 一致) -- 输入「任务名称 + 简介」→「开始分析」→ 各智能体**并行**输出,运行期间**禁止写文件**(产出走正文)。 +- 输入「任务名称 + 简介」→「开始分析」→ 各智能体**并行**输出。运行期间禁用**写文件**(`write_file`/ + `str_replace`)与**派活/脚本**(`agent_orchestration`/`bash`,防子智能体模仿总控调度);检索、查看、 + `ask_clarification` 等照常 —— 所以**步骤条 / 协作卡片正常显示**,每个智能体只做自己那份分析。 - 主区每次只显示**一个**席位的消息列表;**点席位头像切换**要看哪个(单选)。 - 顶部席位状态条显示每个智能体的进行中 / 完成 / 出错。 - 操作区右侧有**模型切换**下拉(拉取后端 `/api/models`);选「默认」则用 `model` prop / 配置 / 后端回退。 diff --git a/parallel-agents-vue/docs/08-开发文档.md b/parallel-agents-vue/docs/08-开发文档.md new file mode 100644 index 00000000..0cf3cd45 --- /dev/null +++ b/parallel-agents-vue/docs/08-开发文档.md @@ -0,0 +1,226 @@ +# 开发文档 · parallel-agents-vue + +面向**维护 / 二次开发**这个前端的同学。讲清楚:它是什么、架构与数据流、目录与各文件职责、 +对接的后端契约、样式系统、以及常见扩展怎么改。使用 / 嵌入请看 [07-使用说明.md](./07-使用说明.md)。 + +--- + +## 1. 它是什么 + +一个**独立、可嵌入**的 Vue3 组件:输入「任务名称 + 简介」→ **并行**调用宿主指定的多个智能体 → +深色消息列表(**1:1 复刻圆桌 Step2**:步骤条 / 协作卡片 / 席位状态条 / 点头像切换单显)→ +支持**保存 / 回显 / 重新分析**。只对外暴露一个组件 `ParallelAgentsPanel`。 + +- 后端是同仓库的 DeerFlow 网关(`offline-backend-20260512/backend`),本前端只消费它的几个 HTTP 接口。 +- 并行**编排在后端**:前端发一条请求,后端扇出 N 个智能体、合并成**一条 SSE**回来;前端只解析这一条流。 + +--- + +## 2. 技术栈 + +| 方面 | 选型 | +| -------- | ----------------------------------------------------------------- | +| 框架 | Vue 3(` + +