feat: 添加MinIO文件系统支持并优化外部连接器功能
- 添加MinIO用户文件系统配置选项(BEAVER_MINIO_ROOT_USER等) - 更新外部连接器配置结构,包括BASE_URL和认证令牌设置 - 改进connector provider支持更多类型(official, feishu_bot等) - 实现Mistral模型推理模式支持reasoning_effort参数 - 增强外部连接器策略配置和运行时配置管理 - 添加connector bridge事件验证和安全保护机制 - 优化任务路由逻辑,区分simple_chat和new_task场景 - 更新初始技能工具提示配置,分离authoring admin功能
This commit is contained in:
18
.env.example
18
.env.example
@ -26,17 +26,27 @@ BEAVER_AUTHZ_URL=http://beaver-authz-service:19090
|
||||
BEAVER_OUTLOOK_MCP_URL=
|
||||
BEAVER_OUTLOOK_MCP_SERVER_ID=outlook_mcp
|
||||
|
||||
# User file system backed by MinIO/S3.
|
||||
BEAVER_MINIO_ROOT_USER=
|
||||
BEAVER_MINIO_ROOT_PASSWORD=
|
||||
BEAVER_USER_FILES_BUCKET=beaver-user-files
|
||||
BEAVER_USER_FILES_MINIO_ENDPOINT=
|
||||
BEAVER_USER_FILES_MAX_UPLOAD_BYTES=5368709120
|
||||
|
||||
# Must be reachable from auth-portal and authz-service containers.
|
||||
BEAVER_DEPLOY_URL=http://beaver-deploy-control:8090
|
||||
|
||||
# External connector sidecar
|
||||
EXTERNAL_CONNECTOR_TOKEN=
|
||||
BEAVER_BRIDGE_TOKEN=
|
||||
EXTERNAL_CONNECTOR_BASE_URL=http://external-connector:8787
|
||||
# Required for connector management API authentication.
|
||||
EXTERNAL_CONNECTOR_TOKEN=change-me-connector-token
|
||||
# Required for sidecar -> Beaver bridge authentication.
|
||||
BEAVER_BRIDGE_TOKEN=change-me-bridge-token
|
||||
BEAVER_BRIDGE_BASE_URL=http://app-instance:8080
|
||||
EXTERNAL_CONNECTOR_PORT=8787
|
||||
CONNECTOR_PUBLIC_BASE_URL=http://localhost:8787
|
||||
# fake | vendor_cli | weixin_ilink
|
||||
CONNECTOR_PROVIDER=vendor_cli
|
||||
# fake | official | vendor_cli | weixin_ilink | feishu_bot
|
||||
CONNECTOR_PROVIDER=official
|
||||
CONNECTOR_COMMAND_TIMEOUT_SECONDS=120
|
||||
WEIXIN_CONNECT_COMMAND=
|
||||
WEIXIN_STATUS_COMMAND=
|
||||
|
||||
@ -72,6 +72,8 @@ docker build -t beaver/app-instance:latest .
|
||||
- `--client-secret`
|
||||
- `--network`
|
||||
- `--host-bind-ip`
|
||||
- `--initial-skills-dir`
|
||||
- `--skip-initial-skills`
|
||||
- `--build`
|
||||
- `--replace`
|
||||
|
||||
@ -127,6 +129,8 @@ BEAVER_WORKSPACE=/root/.beaver/workspace
|
||||
|
||||
所以模型 `provider/api_key/api_base/model` 配一次即可,Web / channel 请求不需要、也不应该携带 API Key。
|
||||
|
||||
`create-instance.sh` 默认会把仓库根目录的 `skills/` 非覆盖式复制到实例 workspace,并把同一个目录只读挂载到实例容器的 `/opt/app/initial-skills`。`entrypoint.sh` 每次启动都会用该目录补齐缺失的 published 初始 skills;已有 skill 目录不会被覆盖,index 只做并集追加。
|
||||
|
||||
## 当前状态
|
||||
|
||||
这层已经支持:
|
||||
|
||||
@ -185,6 +185,13 @@ class LiteLLMProvider(LLMProvider):
|
||||
kwargs["provider"] = provider_payload
|
||||
|
||||
def _apply_thinking_mode(self, original_model: str, resolved_model: str, kwargs: dict[str, Any], enabled: bool | None) -> None:
|
||||
if self._uses_mistral_reasoning_parser(original_model, resolved_model):
|
||||
if enabled is not None:
|
||||
extra_body = dict(kwargs.get("extra_body") or {})
|
||||
extra_body["reasoning_effort"] = "high" if enabled else "none"
|
||||
kwargs["extra_body"] = extra_body
|
||||
return
|
||||
|
||||
extra_body = dict(kwargs.get("extra_body") or {})
|
||||
chat_template_kwargs = dict(extra_body.get("chat_template_kwargs") or {})
|
||||
chat_template_kwargs["enable_thinking"] = False
|
||||
@ -192,6 +199,12 @@ class LiteLLMProvider(LLMProvider):
|
||||
extra_body["thinking"] = {"type": "disabled"}
|
||||
kwargs["extra_body"] = extra_body
|
||||
|
||||
def _uses_mistral_reasoning_parser(self, original_model: str, resolved_model: str) -> bool:
|
||||
if self.provider_name != "vllm":
|
||||
return False
|
||||
model_names = f"{original_model} {resolved_model}".lower()
|
||||
return "mistral" in model_names
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
|
||||
@ -8,6 +8,18 @@ from .models import ChannelRuntimeSpec, ValidationResult
|
||||
from .sidecar_client import ConnectorSidecarClient
|
||||
from .store import ChannelConnectionStore, CredentialStore
|
||||
|
||||
POLICY_CONFIG_KEYS = {
|
||||
"allowFrom",
|
||||
"groupAllowFrom",
|
||||
"requireMentionInGroups",
|
||||
"respondToMentionAll",
|
||||
"dmMode",
|
||||
"maxMessageChars",
|
||||
"textBatchDelayMs",
|
||||
"textBatchMaxMessages",
|
||||
"textBatchMaxChars",
|
||||
}
|
||||
|
||||
|
||||
class ExternalConnectorBase:
|
||||
kind = ""
|
||||
@ -33,6 +45,8 @@ class ExternalConnectorBase:
|
||||
owner_user_id: str | None,
|
||||
options: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
runtime_config = {"sidecarBaseUrl": self.sidecar_base_url}
|
||||
runtime_config.update(_policy_runtime_config(options))
|
||||
connection = self.connection_store.create(
|
||||
kind=self.kind,
|
||||
mode="sidecar",
|
||||
@ -40,7 +54,7 @@ class ExternalConnectorBase:
|
||||
account_id="",
|
||||
owner_user_id=owner_user_id,
|
||||
auth_type="connector_session",
|
||||
runtime_config={"sidecarBaseUrl": self.sidecar_base_url},
|
||||
runtime_config=runtime_config,
|
||||
capabilities=list(self.capabilities),
|
||||
)
|
||||
connection = self.connection_store.update_status(connection.connection_id, status="pairing", last_error=None)
|
||||
@ -54,7 +68,8 @@ class ExternalConnectorBase:
|
||||
}
|
||||
view = dict(await self.sidecar_client.start_session(payload))
|
||||
connection.pairing_session_id = str(view.get("sessionId") or "")
|
||||
self.connection_store.update(connection)
|
||||
connection = self.connection_store.update(connection)
|
||||
connection = self._apply_session_view(connection, view)
|
||||
view["connectionId"] = connection.connection_id
|
||||
view["channelId"] = connection.channel_id
|
||||
return view
|
||||
@ -62,6 +77,12 @@ class ExternalConnectorBase:
|
||||
async def poll_session(self, session_id: str) -> dict[str, Any]:
|
||||
view = dict(await self.sidecar_client.get_session(session_id))
|
||||
connection = self._connection_for_session(session_id)
|
||||
connection = self._apply_session_view(connection, view)
|
||||
view["connectionId"] = connection.connection_id
|
||||
view["channelId"] = connection.channel_id
|
||||
return view
|
||||
|
||||
def _apply_session_view(self, connection: Any, view: dict[str, Any]) -> Any:
|
||||
status = str(view.get("status") or "")
|
||||
if status == "connected":
|
||||
connection.account_id = str(view.get("accountId") or connection.account_id)
|
||||
@ -78,9 +99,7 @@ class ExternalConnectorBase:
|
||||
status="error",
|
||||
last_error=str(view.get("error") or status),
|
||||
)
|
||||
view["connectionId"] = connection.connection_id
|
||||
view["channelId"] = connection.channel_id
|
||||
return view
|
||||
return self.connection_store.get(connection.connection_id)
|
||||
|
||||
async def validate(self, connection_id: str) -> ValidationResult:
|
||||
connection = self.connection_store.get(connection_id)
|
||||
@ -106,6 +125,7 @@ class ExternalConnectorBase:
|
||||
config={
|
||||
"platformKind": self.kind,
|
||||
"connectionId": connection.connection_id,
|
||||
**dict(connection.runtime_config),
|
||||
"sidecarBaseUrl": connection.runtime_config.get("sidecarBaseUrl") or self.sidecar_base_url,
|
||||
},
|
||||
secrets_ref=None,
|
||||
@ -129,3 +149,52 @@ class WeixinConnector(ExternalConnectorBase):
|
||||
class FeishuConnector(ExternalConnectorBase):
|
||||
kind = "feishu"
|
||||
capabilities = ["receive_text", "send_text", "receive_media", "groups"]
|
||||
|
||||
|
||||
def _policy_runtime_config(options: dict[str, Any]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key in POLICY_CONFIG_KEYS:
|
||||
if key not in options:
|
||||
continue
|
||||
value = options[key]
|
||||
if key in {"allowFrom", "groupAllowFrom"}:
|
||||
items = _string_list(value)
|
||||
if items:
|
||||
result[key] = items
|
||||
continue
|
||||
if key in {"maxMessageChars", "textBatchDelayMs", "textBatchMaxMessages", "textBatchMaxChars"}:
|
||||
number = _positive_int(value)
|
||||
if number is not None:
|
||||
result[key] = number
|
||||
continue
|
||||
if key in {"requireMentionInGroups", "respondToMentionAll"}:
|
||||
result[key] = _bool(value)
|
||||
continue
|
||||
text = str(value or "").strip()
|
||||
if text:
|
||||
result[key] = text
|
||||
return result
|
||||
|
||||
|
||||
def _string_list(value: Any) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
raw_items = value.replace("\n", ",").split(",")
|
||||
elif isinstance(value, list):
|
||||
raw_items = value
|
||||
else:
|
||||
raw_items = []
|
||||
return [str(item).strip() for item in raw_items if str(item).strip()]
|
||||
|
||||
|
||||
def _positive_int(value: Any) -> int | None:
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number if number > 0 else None
|
||||
|
||||
|
||||
def _bool(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
@ -431,6 +431,37 @@ def _connection_response_view(connection: Any) -> dict[str, Any]:
|
||||
return view
|
||||
|
||||
|
||||
def _connector_session_response_view(view: dict[str, Any]) -> dict[str, Any]:
|
||||
result = dict(view)
|
||||
metadata = result.get("metadata")
|
||||
if isinstance(metadata, dict):
|
||||
result["metadata"] = {
|
||||
str(key): value
|
||||
for key, value in metadata.items()
|
||||
if not _is_sensitive_metadata_key(str(key))
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _is_sensitive_metadata_key(key: str) -> bool:
|
||||
lowered = key.lower()
|
||||
return any(token in lowered for token in ("secret", "token", "password", "authorization", "credential"))
|
||||
|
||||
|
||||
async def _activate_connected_channel(
|
||||
request: Request,
|
||||
registry: ChannelConnectorRegistry,
|
||||
connection: Any,
|
||||
) -> Any:
|
||||
if connection.status != "connected":
|
||||
return connection
|
||||
runtime = get_channel_runtime(request)
|
||||
config = (await registry.materialize_channel_configs()).get(connection.channel_id)
|
||||
if config is not None:
|
||||
await runtime.add_channel(connection.channel_id, config)
|
||||
return registry.connection_store.get(connection.connection_id)
|
||||
|
||||
|
||||
def _normalize_connection_config(config: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not isinstance(config, dict):
|
||||
return {}
|
||||
@ -441,6 +472,36 @@ def _normalize_connection_config(config: dict[str, Any] | None) -> dict[str, Any
|
||||
}
|
||||
|
||||
|
||||
def _connector_bridge_guard(connection: Any, payload: WebConnectorBridgeEventRequest) -> None:
|
||||
if connection.status == "revoked":
|
||||
raise HTTPException(status_code=404, detail="Channel connection not found")
|
||||
if connection.status not in {"connected", "running"}:
|
||||
raise HTTPException(status_code=409, detail="Channel connection is not connected")
|
||||
mismatches: list[str] = []
|
||||
if payload.channel_id != connection.channel_id:
|
||||
mismatches.append("channelId")
|
||||
if payload.kind != connection.kind:
|
||||
mismatches.append("kind")
|
||||
if payload.account_id != connection.account_id:
|
||||
mismatches.append("accountId")
|
||||
if mismatches:
|
||||
raise HTTPException(status_code=403, detail=f"Bridge event does not match connection: {', '.join(mismatches)}")
|
||||
content = payload.content.strip()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="Bridge event content is required")
|
||||
max_chars = _positive_int(connection.runtime_config.get("maxMessageChars"), default=20000)
|
||||
if len(content) > max_chars:
|
||||
raise HTTPException(status_code=413, detail=f"Bridge event content exceeds maxMessageChars ({max_chars})")
|
||||
|
||||
|
||||
def _positive_int(value: Any, *, default: int) -> int:
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return number if number > 0 else default
|
||||
|
||||
|
||||
def _camel_to_snake_text(value: str) -> str:
|
||||
result: list[str] = []
|
||||
for char in value.strip():
|
||||
@ -721,8 +782,10 @@ def create_app(
|
||||
connection_id = _clean_text(view.get("connectionId"))
|
||||
connection_view = None
|
||||
if connection_id:
|
||||
connection_view = _connection_response_view(registry.connection_store.get(connection_id))
|
||||
return WebConnectorSessionResponse(session=view, connection=connection_view)
|
||||
connection = registry.connection_store.get(connection_id)
|
||||
connection = await _activate_connected_channel(request, registry, connection)
|
||||
connection_view = _connection_response_view(connection)
|
||||
return WebConnectorSessionResponse(session=_connector_session_response_view(view), connection=connection_view)
|
||||
|
||||
@app.get("/api/channel-connector-sessions/{session_id}", response_model=WebConnectorSessionResponse)
|
||||
async def get_channel_connector_session(session_id: str, request: Request) -> WebConnectorSessionResponse:
|
||||
@ -739,11 +802,11 @@ def create_app(
|
||||
raise HTTPException(status_code=400, detail="Connector does not support sessions")
|
||||
view = await poll_session(session_id)
|
||||
connection = registry.connection_store.get(connection.connection_id)
|
||||
if connection.status == "connected":
|
||||
runtime = get_channel_runtime(request)
|
||||
config = (await registry.materialize_channel_configs())[connection.channel_id]
|
||||
await runtime.add_channel(connection.channel_id, config)
|
||||
return WebConnectorSessionResponse(session=view, connection=_connection_response_view(connection))
|
||||
connection = await _activate_connected_channel(request, registry, connection)
|
||||
return WebConnectorSessionResponse(
|
||||
session=_connector_session_response_view(view),
|
||||
connection=_connection_response_view(connection),
|
||||
)
|
||||
|
||||
@app.post("/api/channel-connector-bridge/events", response_model=WebConnectorBridgeEventResponse)
|
||||
async def accept_connector_bridge_event(
|
||||
@ -760,8 +823,7 @@ def create_app(
|
||||
connection = registry.connection_store.get(payload.connection_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Channel connection not found")
|
||||
if connection.status == "revoked":
|
||||
raise HTTPException(status_code=404, detail="Channel connection not found")
|
||||
_connector_bridge_guard(connection, payload)
|
||||
|
||||
store = _message_dedupe_store(_channel_connection_workspace(request))
|
||||
begin = store.begin(
|
||||
|
||||
@ -604,6 +604,8 @@ class AgentService:
|
||||
if active_task is not None and decision.short_title and not active_task.metadata.get("short_title"):
|
||||
active_task.metadata["short_title"] = decision.short_title
|
||||
task_service.store.upsert_task(active_task)
|
||||
if active_task is not None and (decision.action == "simple_chat" or decision.starts_new_task):
|
||||
await self._accept_active_task_for_new_topic(active_task)
|
||||
if active_task is not None and decision.closes_task:
|
||||
task_service.close_task(active_task.task_id, reason=decision.reason)
|
||||
return await runner(message, **kwargs)
|
||||
@ -636,6 +638,20 @@ class AgentService:
|
||||
)
|
||||
return await self._run_task_mode(message, runner=runner, kwargs=kwargs, task=task)
|
||||
|
||||
async def _accept_active_task_for_new_topic(self, task: TaskRecord) -> None:
|
||||
"""Accept a completed active Task before routing an unrelated new topic."""
|
||||
|
||||
if task.status != "awaiting_acceptance":
|
||||
return
|
||||
run_id = next((item for item in reversed(task.run_ids) if item), None)
|
||||
if not run_id:
|
||||
return
|
||||
await self.submit_acceptance(
|
||||
session_id=task.session_id,
|
||||
run_id=run_id,
|
||||
acceptance_type="accept",
|
||||
)
|
||||
|
||||
def _record_revision_acceptance_for_task(
|
||||
self,
|
||||
loaded: Any,
|
||||
|
||||
@ -32,16 +32,23 @@ When there is an active task, do not force every new user message into that task
|
||||
|
||||
- Choose `revise_task` when the user asks to change, correct, refine, expand, reformat, or redo the latest active task result.
|
||||
- Choose `continue_task` for neutral follow-up questions or additional next steps that still belong to the active task.
|
||||
- Choose `new_task` when the user asks for clearly unrelated work.
|
||||
- Choose `simple_chat` for unrelated lightweight conversation. This starts a new topic and the previous task will be accepted automatically.
|
||||
- Choose `new_task` when the user asks for clearly unrelated work that needs Task capabilities. This starts a new topic and the previous task will be accepted automatically.
|
||||
- Choose `close_task` when the user says the task is satisfactory or finished, such as "可以了", "就这样", or "that's good".
|
||||
- Choose `abandon_task` when the user says to stop, cancel, or no longer do the active task.
|
||||
|
||||
Do not classify unrelated lightweight conversation as `revise_task` merely because
|
||||
the active task is awaiting acceptance. A revision must ask to change or correct
|
||||
the active task result.
|
||||
|
||||
Examples with an active weather task:
|
||||
|
||||
- "再详细一点" -> `revise_task`
|
||||
- "加上明后天穿衣建议" -> `revise_task`
|
||||
- "顺便查一下深圳" -> `continue_task`
|
||||
- "帮我写一个采购合同" -> `new_task`
|
||||
- "吃饭没" -> `simple_chat`
|
||||
- "我在冰岛" -> `simple_chat`
|
||||
- "可以了" -> `close_task`
|
||||
- "不用了" -> `abandon_task`
|
||||
|
||||
|
||||
@ -161,6 +161,9 @@ class MainAgentRouter:
|
||||
"Critical policy:\n"
|
||||
"- If there is an active Task, choose continue_task or revise_task unless the user's topic is completely unrelated "
|
||||
"to that Task or the user explicitly closes/abandons it.\n"
|
||||
"- With an active Task, choose simple_chat for unrelated lightweight conversation and new_task for unrelated work "
|
||||
"that needs Task capabilities. Either decision starts a new topic.\n"
|
||||
"- An unrelated lightweight conversation must not be classified as revise_task merely because the active Task is awaiting acceptance.\n"
|
||||
"- Choose revise_task when the active Task is awaiting feedback or needs revision and the user asks for changes "
|
||||
"such as '改一下', '加上', '删除', '换成', '再详细点', '格式改成', '不要', or equivalent wording.\n"
|
||||
"- Choose continue_task for neutral follow-up questions or additional next steps that do not imply dissatisfaction with the previous result.\n"
|
||||
|
||||
@ -34,6 +34,21 @@ def _connected_connection(tmp_path):
|
||||
return connection
|
||||
|
||||
|
||||
def _connection_with_status(tmp_path, status: str):
|
||||
state_dir = tmp_path / "state" / "channel_connections"
|
||||
store = ChannelConnectionStore(state_dir / "connections.json")
|
||||
connection = store.create(
|
||||
kind="feishu",
|
||||
mode="sidecar",
|
||||
display_name="Feishu Main",
|
||||
account_id="feishu:app-1",
|
||||
owner_user_id=None,
|
||||
auth_type="connector_session",
|
||||
)
|
||||
store.update_status(connection.connection_id, status=status, last_error=None)
|
||||
return connection
|
||||
|
||||
|
||||
def _payload(connection, *, event_id: str = "evt-1", delivery_attempt: int = 1) -> dict:
|
||||
return {
|
||||
"eventId": event_id,
|
||||
@ -85,6 +100,77 @@ def test_bridge_endpoint_rejects_invalid_token(tmp_path, monkeypatch) -> None:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_bridge_endpoint_rejects_connection_identity_mismatch(tmp_path, monkeypatch) -> None:
|
||||
app, service = _app(tmp_path, monkeypatch)
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
connection = _connected_connection(tmp_path)
|
||||
payload = _payload(connection)
|
||||
payload["channelId"] = "forged-channel"
|
||||
payload["kind"] = "feishu"
|
||||
payload["accountId"] = "feishu:attacker"
|
||||
response = client.post(
|
||||
"/api/channel-connector-bridge/events",
|
||||
headers={"Authorization": "Bearer bridge-token"},
|
||||
json=payload,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "does not match connection" in response.json()["detail"]
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_bridge_endpoint_rejects_inactive_connection(tmp_path, monkeypatch) -> None:
|
||||
app, service = _app(tmp_path, monkeypatch)
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
connection = _connection_with_status(tmp_path, "pairing")
|
||||
response = client.post(
|
||||
"/api/channel-connector-bridge/events",
|
||||
headers={"Authorization": "Bearer bridge-token"},
|
||||
json={
|
||||
**_payload(connection),
|
||||
"kind": "feishu",
|
||||
"accountId": "feishu:app-1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert "not connected" in response.json()["detail"]
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_bridge_endpoint_rejects_empty_or_oversized_content(tmp_path, monkeypatch) -> None:
|
||||
app, service = _app(tmp_path, monkeypatch)
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
connection = _connection_with_status(tmp_path, "connected")
|
||||
blank = client.post(
|
||||
"/api/channel-connector-bridge/events",
|
||||
headers={"Authorization": "Bearer bridge-token"},
|
||||
json={
|
||||
**_payload(connection, event_id="blank"),
|
||||
"kind": "feishu",
|
||||
"accountId": "feishu:app-1",
|
||||
"content": " ",
|
||||
},
|
||||
)
|
||||
too_long = client.post(
|
||||
"/api/channel-connector-bridge/events",
|
||||
headers={"Authorization": "Bearer bridge-token"},
|
||||
json={
|
||||
**_payload(connection, event_id="too-long"),
|
||||
"kind": "feishu",
|
||||
"accountId": "feishu:app-1",
|
||||
"content": "x" * 20001,
|
||||
},
|
||||
)
|
||||
assert blank.status_code == 400
|
||||
assert too_long.status_code == 413
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_bridge_endpoint_dedupes_repeated_event(tmp_path, monkeypatch) -> None:
|
||||
app, service = _app(tmp_path, monkeypatch)
|
||||
try:
|
||||
|
||||
@ -43,6 +43,22 @@ class FakeSidecarClient:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class ImmediateConnectedSidecarClient(FakeSidecarClient):
|
||||
async def start_session(self, payload: dict) -> dict:
|
||||
self.started.append(payload)
|
||||
session = {
|
||||
"sessionId": "cs_connected",
|
||||
"kind": payload["kind"],
|
||||
"status": "connected",
|
||||
"qrImage": None,
|
||||
"accountId": f"{payload['kind']}:me",
|
||||
"displayName": "Connected Account",
|
||||
"metadata": {"stateRef": "state-1", "appSecret": "secret-1", "tenantAccessToken": "token-1"},
|
||||
}
|
||||
self.sessions["cs_connected"] = session
|
||||
return session
|
||||
|
||||
|
||||
def test_weixin_connector_starts_connector_session(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
@ -67,6 +83,30 @@ def test_weixin_connector_starts_connector_session(tmp_path) -> None:
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_feishu_connector_start_session_connected_updates_connection(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
credential_store = CredentialStore(tmp_path / "credentials.json")
|
||||
client = ImmediateConnectedSidecarClient()
|
||||
connector = FeishuConnector(
|
||||
connection_store=connection_store,
|
||||
credential_store=credential_store,
|
||||
sidecar_client=client,
|
||||
sidecar_base_url="http://external-connector:8787",
|
||||
)
|
||||
|
||||
view = await connector.start_session(display_name="Feishu Main", owner_user_id=None, options={})
|
||||
connection = connection_store.get(view["connectionId"])
|
||||
|
||||
assert view["status"] == "connected"
|
||||
assert connection.status == "connected"
|
||||
assert connection.account_id == "feishu:me"
|
||||
assert connection.display_name == "Connected Account"
|
||||
assert connection.credentials_ref is not None
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_weixin_connector_poll_connected_materializes_external_runtime(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
@ -124,6 +164,67 @@ def test_feishu_connector_uses_feishu_kind(tmp_path) -> None:
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_feishu_connector_persists_policy_options_in_runtime_config(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
credential_store = CredentialStore(tmp_path / "credentials.json")
|
||||
client = FakeSidecarClient()
|
||||
connector = FeishuConnector(
|
||||
connection_store=connection_store,
|
||||
credential_store=credential_store,
|
||||
sidecar_client=client,
|
||||
sidecar_base_url="http://external-connector:8787",
|
||||
)
|
||||
|
||||
await connector.start_session(
|
||||
display_name="Feishu Main",
|
||||
owner_user_id=None,
|
||||
options={
|
||||
"domain": "feishu",
|
||||
"requireMentionInGroups": True,
|
||||
"allowFrom": ["ou_1"],
|
||||
"groupAllowFrom": ["oc_1"],
|
||||
"maxMessageChars": 1234,
|
||||
},
|
||||
)
|
||||
connection = connection_store.list()[0]
|
||||
|
||||
assert client.started[0]["options"]["requireMentionInGroups"] is True
|
||||
assert connection.runtime_config["requireMentionInGroups"] is True
|
||||
assert connection.runtime_config["allowFrom"] == ["ou_1"]
|
||||
assert connection.runtime_config["groupAllowFrom"] == ["oc_1"]
|
||||
assert connection.runtime_config["maxMessageChars"] == 1234
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_feishu_connector_materializes_policy_for_external_runtime(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
credential_store = CredentialStore(tmp_path / "credentials.json")
|
||||
client = ImmediateConnectedSidecarClient()
|
||||
connector = FeishuConnector(
|
||||
connection_store=connection_store,
|
||||
credential_store=credential_store,
|
||||
sidecar_client=client,
|
||||
sidecar_base_url="http://external-connector:8787",
|
||||
)
|
||||
|
||||
view = await connector.start_session(
|
||||
display_name="Feishu Main",
|
||||
owner_user_id=None,
|
||||
options={"requireMentionInGroups": True, "allowFrom": ["ou_1"], "groupAllowFrom": ["oc_1"]},
|
||||
)
|
||||
spec = await connector.materialize_runtime(view["connectionId"])
|
||||
|
||||
assert spec.config["platformKind"] == "feishu"
|
||||
assert spec.config["requireMentionInGroups"] is True
|
||||
assert spec.config["allowFrom"] == ["ou_1"]
|
||||
assert spec.config["groupAllowFrom"] == ["oc_1"]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_connector_session_api_starts_and_polls_connected_session(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token")
|
||||
config_path = tmp_path / "config.json"
|
||||
@ -174,3 +275,44 @@ def test_connector_session_api_starts_and_polls_connected_session(tmp_path, monk
|
||||
assert polled.json()["connection"]["channel_id"] in app.state.channel_runtime.adapters
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_connector_session_api_activates_immediate_connected_session(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token")
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
'{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path),
|
||||
encoding="utf-8",
|
||||
)
|
||||
service = AgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
client = ImmediateConnectedSidecarClient()
|
||||
|
||||
try:
|
||||
with TestClient(app) as http:
|
||||
state_dir = tmp_path / "state" / "channel_connections"
|
||||
connection_store = ChannelConnectionStore(state_dir / "connections.json")
|
||||
credential_store = CredentialStore(state_dir / "credentials.json")
|
||||
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
|
||||
registry.register(
|
||||
FeishuConnector(
|
||||
connection_store=connection_store,
|
||||
credential_store=credential_store,
|
||||
sidecar_client=client,
|
||||
sidecar_base_url="http://external-connector:8787",
|
||||
)
|
||||
)
|
||||
app.state.channel_connector_registry = registry
|
||||
|
||||
started = http.post(
|
||||
"/api/channel-connector-sessions",
|
||||
json={"kind": "feishu", "displayName": "Feishu Main", "options": {}},
|
||||
)
|
||||
|
||||
assert started.status_code == 200
|
||||
connection = started.json()["connection"]
|
||||
assert connection["status"] == "connected"
|
||||
assert connection["channel_id"] in app.state.channel_runtime.adapters
|
||||
assert started.json()["session"]["metadata"] == {"stateRef": "state-1"}
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
@ -30,10 +30,14 @@ EXPECTED_INITIAL_SKILL_TOOLS = {
|
||||
"mcp_outlook_mcp_calendar_find_meeting_times",
|
||||
"mcp_outlook_mcp_calendar_delta_sync",
|
||||
],
|
||||
"skills-admin": ["skills_list", "skill_manage", "skill_view"],
|
||||
"skills-admin": ["skills_list", "skill_view"],
|
||||
"terminal-operation": ["terminal", "process", "execute_code"],
|
||||
"utility-tools": ["clarify", "delegate", "send_message", "spawn", "todo"],
|
||||
"web-operation": ["web_fetch", "web_search"],
|
||||
"multi-search-engine": ["web_fetch"],
|
||||
}
|
||||
|
||||
EXPECTED_NON_INITIAL_SKILL_TOOLS = {
|
||||
"skills-authoring-admin": ["skill_manage"],
|
||||
}
|
||||
|
||||
|
||||
@ -48,6 +52,23 @@ def test_initial_skill_tool_hints_match_runtime_tool_names() -> None:
|
||||
assert version["tool_hints"] == expected_tools
|
||||
|
||||
|
||||
def test_skill_authoring_admin_is_seeded_but_not_initial() -> None:
|
||||
published = json.loads((REPO_ROOT / "skills" / "_index" / "published.json").read_text(encoding="utf-8"))
|
||||
disabled = json.loads((REPO_ROOT / "skills" / "_index" / "disabled.json").read_text(encoding="utf-8"))
|
||||
|
||||
assert "skills-authoring-admin" not in published["items"]
|
||||
assert "skills-authoring-admin" in disabled["items"]
|
||||
|
||||
for skill_name, expected_tools in EXPECTED_NON_INITIAL_SKILL_TOOLS.items():
|
||||
skill_dir = REPO_ROOT / "skills" / skill_name / "versions" / "v0001"
|
||||
frontmatter, _body = parse_frontmatter((skill_dir / "SKILL.md").read_text(encoding="utf-8"))
|
||||
version = json.loads((skill_dir / "version.json").read_text(encoding="utf-8"))
|
||||
|
||||
assert frontmatter["tools"] == expected_tools
|
||||
assert version["frontmatter"]["tools"] == expected_tools
|
||||
assert version["tool_hints"] == expected_tools
|
||||
|
||||
|
||||
def test_default_runtime_registers_skill_view_tool(tmp_path: Path) -> None:
|
||||
loaded = EngineLoader(workspace=tmp_path).load()
|
||||
try:
|
||||
|
||||
@ -169,6 +169,90 @@ def test_thinking_mode_is_forced_disabled_even_when_requested_enabled(monkeypatc
|
||||
}
|
||||
|
||||
|
||||
def test_mistral_vllm_uses_reasoning_effort_instead_of_qwen_thinking_body(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
class Message:
|
||||
content = "ok"
|
||||
reasoning_content = None
|
||||
tool_calls = []
|
||||
|
||||
class Choice:
|
||||
message = Message()
|
||||
finish_reason = "stop"
|
||||
|
||||
class Response:
|
||||
choices = [Choice()]
|
||||
usage = None
|
||||
|
||||
async def fake_acompletion(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion)
|
||||
monkeypatch.setattr("beaver.engine.providers.litellm.litellm", SimpleNamespace())
|
||||
|
||||
provider = LiteLLMProvider(
|
||||
api_key="EMPTY",
|
||||
api_base="http://localhost:8000/v1",
|
||||
default_model="mistralai/Mistral-Medium-3.5-128B",
|
||||
provider_name="vllm",
|
||||
)
|
||||
asyncio.run(
|
||||
provider.chat(
|
||||
[{"role": "user", "content": "reply ok"}],
|
||||
model="mistralai/Mistral-Medium-3.5-128B",
|
||||
thinking_enabled=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert captured["model"] == "hosted_vllm/mistralai/Mistral-Medium-3.5-128B"
|
||||
assert captured["extra_body"] == {"reasoning_effort": "high"}
|
||||
|
||||
|
||||
def test_mistral_vllm_omits_reasoning_body_when_thinking_mode_is_unspecified(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
class Message:
|
||||
content = "ok"
|
||||
reasoning_content = None
|
||||
tool_calls = []
|
||||
|
||||
class Choice:
|
||||
message = Message()
|
||||
finish_reason = "stop"
|
||||
|
||||
class Response:
|
||||
choices = [Choice()]
|
||||
usage = None
|
||||
|
||||
async def fake_acompletion(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion)
|
||||
monkeypatch.setattr("beaver.engine.providers.litellm.litellm", SimpleNamespace())
|
||||
|
||||
provider = LiteLLMProvider(
|
||||
api_key="EMPTY",
|
||||
api_base="http://localhost:8000/v1",
|
||||
default_model="mistralai/Mistral-Medium-3.5-128B",
|
||||
provider_name="vllm",
|
||||
)
|
||||
asyncio.run(
|
||||
provider.chat(
|
||||
[{"role": "user", "content": "reply ok"}],
|
||||
model="mistralai/Mistral-Medium-3.5-128B",
|
||||
)
|
||||
)
|
||||
|
||||
assert "extra_body" not in captured
|
||||
|
||||
|
||||
def test_litellm_provider_sanitizes_tool_call_arguments(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
|
||||
@ -149,6 +149,22 @@ def test_router_injects_intent_skill_guidance() -> None:
|
||||
assert "Weather and current external data" in prompt
|
||||
|
||||
|
||||
def test_router_prompt_treats_unrelated_lightweight_conversation_as_new_topic() -> None:
|
||||
provider = RouterProvider('{"action":"simple_chat","reason":"unrelated lightweight conversation"}')
|
||||
|
||||
asyncio.run(
|
||||
MainAgentRouter().classify(
|
||||
"吃饭没",
|
||||
active_task=_task(),
|
||||
provider=provider,
|
||||
)
|
||||
)
|
||||
|
||||
prompt = provider.calls[0]["messages"][1]["content"]
|
||||
assert "unrelated lightweight conversation" in prompt
|
||||
assert "must not be classified as revise_task merely because the active Task is awaiting acceptance" in prompt
|
||||
|
||||
|
||||
def test_router_closes_active_task_from_llm_decision() -> None:
|
||||
decision = asyncio.run(
|
||||
MainAgentRouter().classify(
|
||||
|
||||
@ -99,6 +99,191 @@ def test_task_run_records_evidence_and_waits_for_acceptance(tmp_path: Path) -> N
|
||||
assert "validated" not in event_types
|
||||
|
||||
|
||||
def test_unrelated_simple_chat_auto_accepts_active_task(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=StubTaskExecutionPlanner(),
|
||||
)
|
||||
)
|
||||
first = asyncio.run(
|
||||
service.process_direct(
|
||||
"recommend food in Hengqin",
|
||||
session_id="web:new-topic-chat",
|
||||
provider_bundle=_bundle("Food recommendations"),
|
||||
)
|
||||
)
|
||||
|
||||
second = asyncio.run(
|
||||
service.process_direct(
|
||||
"have you eaten?",
|
||||
session_id="web:new-topic-chat",
|
||||
provider_bundle=_bundle("I do not eat.", route_action="simple_chat"),
|
||||
)
|
||||
)
|
||||
|
||||
task_service = service.create_loop().boot().task_service
|
||||
assert task_service is not None
|
||||
previous = task_service.get_task(first.task_id or "")
|
||||
assert previous is not None
|
||||
assert previous.status == "closed"
|
||||
assert previous.run_ids == [first.run_id]
|
||||
assert previous.feedback[-1]["acceptance_type"] == "accept"
|
||||
assert previous.metadata["final_accepted_run_id"] == first.run_id
|
||||
assert second.task_id is None
|
||||
|
||||
|
||||
def test_unrelated_new_task_auto_accepts_previous_task(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=StubTaskExecutionPlanner(),
|
||||
)
|
||||
)
|
||||
first = asyncio.run(
|
||||
service.process_direct(
|
||||
"recommend food in Hengqin",
|
||||
session_id="web:new-topic-task",
|
||||
provider_bundle=_bundle("Food recommendations"),
|
||||
)
|
||||
)
|
||||
|
||||
second = asyncio.run(
|
||||
service.process_direct(
|
||||
"check today's weather in Iceland",
|
||||
session_id="web:new-topic-task",
|
||||
provider_bundle=_bundle("Weather result", route_action="new_task"),
|
||||
)
|
||||
)
|
||||
|
||||
task_service = service.create_loop().boot().task_service
|
||||
assert task_service is not None
|
||||
previous = task_service.get_task(first.task_id or "")
|
||||
current = task_service.get_task(second.task_id or "")
|
||||
assert previous is not None
|
||||
assert current is not None
|
||||
assert previous.status == "closed"
|
||||
assert previous.run_ids == [first.run_id]
|
||||
assert previous.feedback[-1]["acceptance_type"] == "accept"
|
||||
assert current.task_id != previous.task_id
|
||||
assert current.status == "awaiting_acceptance"
|
||||
assert current.run_ids == [second.run_id]
|
||||
|
||||
|
||||
def test_related_follow_up_continues_active_task_without_accepting_it(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=StubTaskExecutionPlanner(),
|
||||
)
|
||||
)
|
||||
first = asyncio.run(
|
||||
service.process_direct(
|
||||
"recommend food in Hengqin",
|
||||
session_id="web:continue-topic",
|
||||
provider_bundle=_bundle("Food recommendations"),
|
||||
)
|
||||
)
|
||||
|
||||
second = asyncio.run(
|
||||
service.process_direct(
|
||||
"include restaurants near the port",
|
||||
session_id="web:continue-topic",
|
||||
provider_bundle=_bundle("More recommendations", route_action="continue_task"),
|
||||
)
|
||||
)
|
||||
|
||||
task_service = service.create_loop().boot().task_service
|
||||
assert task_service is not None
|
||||
task = task_service.get_task(first.task_id or "")
|
||||
assert task is not None
|
||||
assert second.task_id == first.task_id
|
||||
assert task.status == "awaiting_acceptance"
|
||||
assert task.run_ids == [first.run_id, second.run_id]
|
||||
assert task.feedback == []
|
||||
|
||||
|
||||
def test_requested_revision_keeps_active_task_without_accepting_it(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=StubTaskExecutionPlanner(),
|
||||
)
|
||||
)
|
||||
first = asyncio.run(
|
||||
service.process_direct(
|
||||
"recommend food in Hengqin",
|
||||
session_id="web:revise-topic",
|
||||
provider_bundle=_bundle("Food recommendations"),
|
||||
)
|
||||
)
|
||||
|
||||
second = asyncio.run(
|
||||
service.process_direct(
|
||||
"remove expensive restaurants",
|
||||
session_id="web:revise-topic",
|
||||
provider_bundle=_bundle("Revised recommendations", route_action="revise_task"),
|
||||
)
|
||||
)
|
||||
|
||||
task_service = service.create_loop().boot().task_service
|
||||
assert task_service is not None
|
||||
task = task_service.get_task(first.task_id or "")
|
||||
assert task is not None
|
||||
assert second.task_id == first.task_id
|
||||
assert task.status == "awaiting_acceptance"
|
||||
assert task.run_ids == [first.run_id, second.run_id]
|
||||
assert [item["acceptance_type"] for item in task.feedback] == ["revise"]
|
||||
|
||||
|
||||
def test_router_failure_fallback_does_not_auto_accept_active_task(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=StubTaskExecutionPlanner(),
|
||||
)
|
||||
)
|
||||
first = asyncio.run(
|
||||
service.process_direct(
|
||||
"recommend food in Hengqin",
|
||||
session_id="web:router-fallback",
|
||||
provider_bundle=_bundle("Food recommendations"),
|
||||
)
|
||||
)
|
||||
fallback_bundle = ProviderBundle(
|
||||
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||
main_provider=StubProvider(
|
||||
[
|
||||
LLMResponse(
|
||||
content="Continued response",
|
||||
finish_reason="stop",
|
||||
provider_name="stub",
|
||||
model="stub-model",
|
||||
)
|
||||
]
|
||||
),
|
||||
auxiliary_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||
auxiliary_provider=StubProvider([]),
|
||||
)
|
||||
|
||||
second = asyncio.run(
|
||||
service.process_direct(
|
||||
"continue after router failure",
|
||||
session_id="web:router-fallback",
|
||||
provider_bundle=fallback_bundle,
|
||||
)
|
||||
)
|
||||
|
||||
task_service = service.create_loop().boot().task_service
|
||||
assert task_service is not None
|
||||
task = task_service.get_task(first.task_id or "")
|
||||
assert task is not None
|
||||
assert second.task_id == first.task_id
|
||||
assert task.status == "awaiting_acceptance"
|
||||
assert task.run_ids == [first.run_id, second.run_id]
|
||||
assert task.feedback == []
|
||||
|
||||
|
||||
def test_acceptance_closes_task_and_triggers_learning(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
|
||||
@ -19,6 +19,9 @@ AUTHZ_INTERNAL_TOKEN=""
|
||||
AUTHZ_OUTLOOK_MCP_URL=""
|
||||
OUTLOOK_MCP_SERVER_ID="${OUTLOOK_MCP_SERVER_ID:-outlook_mcp}"
|
||||
USER_FILES_MAX_UPLOAD_BYTES="${USER_FILES_MAX_UPLOAD_BYTES:-}"
|
||||
EXTERNAL_CONNECTOR_BASE_URL="${EXTERNAL_CONNECTOR_BASE_URL:-http://external-connector:8787}"
|
||||
EXTERNAL_CONNECTOR_TOKEN="${EXTERNAL_CONNECTOR_TOKEN:-}"
|
||||
BEAVER_BRIDGE_TOKEN="${BEAVER_BRIDGE_TOKEN:-}"
|
||||
BACKEND_ID=""
|
||||
CLIENT_ID=""
|
||||
CLIENT_SECRET=""
|
||||
@ -40,6 +43,7 @@ REGISTRY_PATH="${REGISTRY_PATH:-$REGISTRY_PATH_DEFAULT}"
|
||||
NETWORK_NAME="${NETWORK_NAME:-}"
|
||||
HOST_BIND_IP="${HOST_BIND_IP:-127.0.0.1}"
|
||||
INITIAL_SKILLS_DIR="${INITIAL_SKILLS_DIR:-${SCRIPT_DIR}/../skills}"
|
||||
INITIAL_SKILLS_EXCLUDE="${INITIAL_SKILLS_EXCLUDE:-officebench-mcp}"
|
||||
SEED_INITIAL_SKILLS=1
|
||||
FORCE_BUILD=0
|
||||
REPLACE=0
|
||||
@ -73,6 +77,11 @@ Optional:
|
||||
Default Outlook MCP server id. Default: outlook_mcp
|
||||
--user-files-max-upload-bytes <bytes>
|
||||
Optional max upload size for the user file system.
|
||||
--external-connector-base-url <url>
|
||||
External connector sidecar URL. Default: http://external-connector:8787
|
||||
--external-connector-token <token>
|
||||
Service token used for Beaver-to-sidecar requests.
|
||||
--bridge-token <token> Service token accepted from the connector bridge.
|
||||
--backend-id <id> Pre-assigned backend id.
|
||||
--client-id <id> Pre-assigned AuthZ client id.
|
||||
--client-secret <secret> Pre-assigned AuthZ client secret.
|
||||
@ -299,6 +308,15 @@ data = {
|
||||
"textBatchDelaySeconds": 0.5,
|
||||
},
|
||||
},
|
||||
"terminal-dev": {
|
||||
"enabled": True,
|
||||
"kind": "terminal",
|
||||
"mode": "websocket",
|
||||
"accountId": "local",
|
||||
"displayName": "Terminal Dev",
|
||||
"config": {},
|
||||
"secrets": {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -344,7 +362,7 @@ seed_initial_skills() {
|
||||
fi
|
||||
|
||||
mkdir -p "$target_dir"
|
||||
INITIAL_SKILLS_DIR="$initial_skills_dir" TARGET_DIR="$target_dir" python3 - <<'PY'
|
||||
INITIAL_SKILLS_DIR="$initial_skills_dir" TARGET_DIR="$target_dir" INITIAL_SKILLS_EXCLUDE="$INITIAL_SKILLS_EXCLUDE" python3 - <<'PY'
|
||||
import json
|
||||
import shutil
|
||||
import os
|
||||
@ -352,10 +370,13 @@ from pathlib import Path
|
||||
|
||||
initial = Path(os.environ["INITIAL_SKILLS_DIR"]).resolve()
|
||||
target = Path(os.environ["TARGET_DIR"]).resolve()
|
||||
excluded = {item.strip() for item in os.environ.get("INITIAL_SKILLS_EXCLUDE", "").split(",") if item.strip()}
|
||||
|
||||
for child in sorted(initial.iterdir()):
|
||||
if child.name.startswith("."):
|
||||
continue
|
||||
if child.name in excluded:
|
||||
continue
|
||||
destination = target / child.name
|
||||
if destination.exists():
|
||||
continue
|
||||
@ -383,6 +404,8 @@ for index_name in ("published", "disabled"):
|
||||
merged = []
|
||||
for item in [*target_items, *initial_items]:
|
||||
text = str(item).strip()
|
||||
if text in excluded:
|
||||
continue
|
||||
if text and text not in merged:
|
||||
merged.append(text)
|
||||
target_index.parent.mkdir(parents=True, exist_ok=True)
|
||||
@ -534,6 +557,18 @@ while [[ $# -gt 0 ]]; do
|
||||
USER_FILES_MAX_UPLOAD_BYTES="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--external-connector-base-url)
|
||||
EXTERNAL_CONNECTOR_BASE_URL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--external-connector-token)
|
||||
EXTERNAL_CONNECTOR_TOKEN="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--bridge-token)
|
||||
BEAVER_BRIDGE_TOKEN="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--backend-id)
|
||||
BACKEND_ID="${2:-}"
|
||||
shift 2
|
||||
@ -643,6 +678,16 @@ if [[ "$SKIP_PROVIDER_CONFIG" -ne 1 ]]; then
|
||||
esac
|
||||
fi
|
||||
|
||||
if [[ -n "$INITIAL_SKILLS_DIR" ]]; then
|
||||
INITIAL_SKILLS_DIR="$(INITIAL_SKILLS_DIR="$INITIAL_SKILLS_DIR" python3 - <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
print(Path(os.environ["INITIAL_SKILLS_DIR"]).expanduser().resolve())
|
||||
PY
|
||||
)"
|
||||
fi
|
||||
|
||||
if [[ -n "$BACKEND_ID$CLIENT_ID$CLIENT_SECRET" ]]; then
|
||||
[[ -n "$BACKEND_ID" && -n "$CLIENT_ID" && -n "$CLIENT_SECRET" ]] || die "backend identity requires --backend-id, --client-id and --client-secret together"
|
||||
fi
|
||||
@ -721,14 +766,29 @@ RUN_ARGS=(
|
||||
-e "APP_BACKEND_PORT=18080"
|
||||
-e "BEAVER_ENABLE_SELF_RESTART=1"
|
||||
-e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}"
|
||||
-e "EXTERNAL_CONNECTOR_BASE_URL=${EXTERNAL_CONNECTOR_BASE_URL}"
|
||||
--label "beaver.instance.id=${INSTANCE_ID}"
|
||||
--label "beaver.instance.slug=${INSTANCE_SLUG}"
|
||||
--label "beaver.instance.public_url=${PUBLIC_URL}"
|
||||
)
|
||||
|
||||
if [[ "$SEED_INITIAL_SKILLS" -eq 1 && -n "$INITIAL_SKILLS_DIR" ]]; then
|
||||
RUN_ARGS+=(
|
||||
-v "${INITIAL_SKILLS_DIR}:/opt/app/initial-skills:ro"
|
||||
-e "BEAVER_INITIAL_SKILLS_DIR=/opt/app/initial-skills"
|
||||
-e "BEAVER_INITIAL_SKILLS_EXCLUDE=${INITIAL_SKILLS_EXCLUDE}"
|
||||
)
|
||||
fi
|
||||
|
||||
if [[ -n "$USER_FILES_MAX_UPLOAD_BYTES" ]]; then
|
||||
RUN_ARGS+=(-e "BEAVER_USER_FILES_MAX_UPLOAD_BYTES=${USER_FILES_MAX_UPLOAD_BYTES}")
|
||||
fi
|
||||
if [[ -n "$EXTERNAL_CONNECTOR_TOKEN" ]]; then
|
||||
RUN_ARGS+=(-e "EXTERNAL_CONNECTOR_TOKEN=${EXTERNAL_CONNECTOR_TOKEN}")
|
||||
fi
|
||||
if [[ -n "$BEAVER_BRIDGE_TOKEN" ]]; then
|
||||
RUN_ARGS+=(-e "BEAVER_BRIDGE_TOKEN=${BEAVER_BRIDGE_TOKEN}")
|
||||
fi
|
||||
|
||||
if [[ -n "$NETWORK_NAME" ]]; then
|
||||
RUN_ARGS+=(--network "$NETWORK_NAME")
|
||||
|
||||
@ -12,6 +12,8 @@ BEAVER_CONFIG_PATH="${BEAVER_CONFIG_PATH:-$BEAVER_HOME/config.json}"
|
||||
BEAVER_WORKSPACE="${BEAVER_WORKSPACE:-$BEAVER_HOME/workspace}"
|
||||
BEAVER_AUTH_FILE="${BEAVER_AUTH_FILE:-$BEAVER_HOME/web_auth_users.json}"
|
||||
BEAVER_RUNTIME_ENV_FILE="${BEAVER_RUNTIME_ENV_FILE:-$BEAVER_HOME/runtime.env}"
|
||||
BEAVER_INITIAL_SKILLS_DIR="${BEAVER_INITIAL_SKILLS_DIR:-/opt/app/initial-skills}"
|
||||
BEAVER_INITIAL_SKILLS_EXCLUDE="${BEAVER_INITIAL_SKILLS_EXCLUDE:-officebench-mcp}"
|
||||
|
||||
log() {
|
||||
printf '[app-instance] %s\n' "$*"
|
||||
@ -26,6 +28,68 @@ require_file() {
|
||||
fi
|
||||
}
|
||||
|
||||
seed_initial_skills() {
|
||||
local initial_skills_dir="$1"
|
||||
local target_dir="$2"
|
||||
|
||||
if [[ ! -d "$initial_skills_dir" ]]; then
|
||||
return
|
||||
fi
|
||||
if [[ ! -f "$initial_skills_dir/_index/published.json" ]]; then
|
||||
log "initial skills source has no published index, skipping: ${initial_skills_dir}"
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$target_dir"
|
||||
INITIAL_SKILLS_DIR="$initial_skills_dir" TARGET_DIR="$target_dir" INITIAL_SKILLS_EXCLUDE="$BEAVER_INITIAL_SKILLS_EXCLUDE" python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
initial = Path(os.environ["INITIAL_SKILLS_DIR"]).resolve()
|
||||
target = Path(os.environ["TARGET_DIR"]).resolve()
|
||||
excluded = {item.strip() for item in os.environ.get("INITIAL_SKILLS_EXCLUDE", "").split(",") if item.strip()}
|
||||
|
||||
for child in sorted(initial.iterdir()):
|
||||
if child.name.startswith(".") or child.name in excluded:
|
||||
continue
|
||||
destination = target / child.name
|
||||
if destination.exists():
|
||||
continue
|
||||
if child.is_dir():
|
||||
shutil.copytree(child, destination)
|
||||
elif child.is_file():
|
||||
shutil.copy2(child, destination)
|
||||
|
||||
for index_name in ("published", "disabled"):
|
||||
initial_index = initial / "_index" / f"{index_name}.json"
|
||||
target_index = target / "_index" / f"{index_name}.json"
|
||||
if not initial_index.exists():
|
||||
continue
|
||||
try:
|
||||
initial_items = json.loads(initial_index.read_text(encoding="utf-8")).get("items", [])
|
||||
except json.JSONDecodeError:
|
||||
initial_items = []
|
||||
if target_index.exists():
|
||||
try:
|
||||
target_items = json.loads(target_index.read_text(encoding="utf-8")).get("items", [])
|
||||
except json.JSONDecodeError:
|
||||
target_items = []
|
||||
else:
|
||||
target_items = []
|
||||
merged = []
|
||||
for item in [*target_items, *initial_items]:
|
||||
text = str(item).strip()
|
||||
if text in excluded:
|
||||
continue
|
||||
if text and text not in merged:
|
||||
merged.append(text)
|
||||
target_index.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_index.write_text(json.dumps({"items": merged}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
PY
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local status=$?
|
||||
|
||||
@ -54,12 +118,15 @@ if [[ -f "$BEAVER_RUNTIME_ENV_FILE" ]]; then
|
||||
fi
|
||||
|
||||
require_file "$BEAVER_CONFIG_PATH" "Missing Beaver config"
|
||||
seed_initial_skills "$BEAVER_INITIAL_SKILLS_DIR" "$BEAVER_WORKSPACE/skills"
|
||||
|
||||
export BEAVER_AUTH_FILE
|
||||
export BEAVER_RUNTIME_ENV_FILE
|
||||
export BEAVER_HOME
|
||||
export BEAVER_CONFIG_PATH
|
||||
export BEAVER_WORKSPACE
|
||||
export BEAVER_INITIAL_SKILLS_DIR
|
||||
export BEAVER_INITIAL_SKILLS_EXCLUDE
|
||||
export PORT="$APP_FRONTEND_PORT"
|
||||
export HOSTNAME="127.0.0.1"
|
||||
export PYTHONFAULTHANDLER="${PYTHONFAULTHANDLER:-1}"
|
||||
|
||||
@ -34,6 +34,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { type AppLocale, pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { containedLongTextClass, containedPreservedLongTextClass } from '@/lib/text-wrapping';
|
||||
|
||||
const LOAD_RETRY_DELAYS_MS = [0, 600, 1200];
|
||||
|
||||
@ -126,8 +127,8 @@ export default function FilesPage() {
|
||||
if (selectedFile?.path === item.path) {
|
||||
setSelectedFile(null);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (err: any) {
|
||||
setLoadError(err.message || pickAppText(locale, '删除失败', 'Delete failed'));
|
||||
}
|
||||
};
|
||||
|
||||
@ -147,8 +148,8 @@ export default function FilesPage() {
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(a.href);
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (err: any) {
|
||||
setPreviewError(err.message || pickAppText(locale, '下载失败', 'Download failed'));
|
||||
}
|
||||
};
|
||||
|
||||
@ -160,13 +161,13 @@ export default function FilesPage() {
|
||||
setUploadProgress(0);
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
await uploadUserFile(files[i], currentPath || 'uploads', (pct) => {
|
||||
await uploadUserFile(files[i], currentPath, (pct) => {
|
||||
setUploadProgress(Math.round((i / files.length) * 100 + pct / files.length));
|
||||
});
|
||||
}
|
||||
await load();
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (err: any) {
|
||||
setLoadError(err.message || pickAppText(locale, '上传失败', 'Upload failed'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
@ -183,8 +184,8 @@ export default function FilesPage() {
|
||||
setShowMkdir(false);
|
||||
setNewDirName('');
|
||||
await load();
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (err: any) {
|
||||
setLoadError(err.message || pickAppText(locale, '创建文件夹失败', 'Failed to create folder'));
|
||||
}
|
||||
};
|
||||
|
||||
@ -213,16 +214,17 @@ export default function FilesPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl p-6">
|
||||
<div className="mx-auto w-full max-w-7xl overflow-x-hidden px-4 py-6 sm:px-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-bold">{pickAppText(locale, '文件管理', 'Files')}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-11"
|
||||
onClick={() => setShowMkdir(true)}
|
||||
disabled={loading || !currentPath}
|
||||
disabled={loading}
|
||||
>
|
||||
<FolderPlus className="w-4 h-4 mr-1" />
|
||||
{pickAppText(locale, '新建文件夹', 'New folder')}
|
||||
@ -230,8 +232,9 @@ export default function FilesPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-11"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading || !currentPath}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
@ -252,7 +255,15 @@ export default function FilesPage() {
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => load()} disabled={loading}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
onClick={() => load()}
|
||||
disabled={loading}
|
||||
aria-label={pickAppText(locale, '刷新', 'Refresh')}
|
||||
title={pickAppText(locale, '刷新', 'Refresh')}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
@ -266,7 +277,7 @@ export default function FilesPage() {
|
||||
<div className="flex items-center gap-1 mb-4 text-sm text-muted-foreground flex-wrap">
|
||||
<button
|
||||
onClick={() => navigateTo('')}
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors px-1.5 py-0.5 rounded hover:bg-accent"
|
||||
className="inline-flex h-11 items-center gap-1 rounded px-2 transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Home className="w-3.5 h-3.5" />
|
||||
{pickAppText(locale, '文件', 'Files')}
|
||||
@ -279,7 +290,7 @@ export default function FilesPage() {
|
||||
<ChevronRight className="w-3 h-3 flex-shrink-0" />
|
||||
<button
|
||||
onClick={() => navigateTo(path)}
|
||||
className={`px-1.5 py-0.5 rounded transition-colors ${
|
||||
className={`inline-flex h-11 items-center rounded px-2 text-left transition-colors ${containedLongTextClass} ${
|
||||
isLast
|
||||
? 'text-foreground font-medium'
|
||||
: 'hover:text-foreground hover:bg-accent'
|
||||
@ -294,9 +305,13 @@ export default function FilesPage() {
|
||||
|
||||
{/* New directory input */}
|
||||
{showMkdir && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<Folder className="w-4 h-4 text-muted-foreground" />
|
||||
<label htmlFor="new-folder-name" className="sr-only">
|
||||
{pickAppText(locale, '文件夹名称', 'Folder name')}
|
||||
</label>
|
||||
<input
|
||||
id="new-folder-name"
|
||||
ref={mkdirInputRef}
|
||||
type="text"
|
||||
value={newDirName}
|
||||
@ -309,15 +324,16 @@ export default function FilesPage() {
|
||||
}
|
||||
}}
|
||||
placeholder={pickAppText(locale, '文件夹名称', 'Folder name')}
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
className="h-11 min-w-0 flex-1 rounded-md border border-border bg-background px-3 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" onClick={handleCreateDir}>
|
||||
<Button size="sm" className="h-11" onClick={handleCreateDir}>
|
||||
{pickAppText(locale, '创建', 'Create')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-11"
|
||||
onClick={() => {
|
||||
setShowMkdir(false);
|
||||
setNewDirName('');
|
||||
@ -328,9 +344,9 @@ export default function FilesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(360px,440px)_minmax(0,1fr)]">
|
||||
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-4 lg:grid-cols-[minmax(360px,440px)_minmax(0,1fr)]">
|
||||
{/* File list */}
|
||||
<div className="min-h-[520px] rounded-lg border border-border bg-card">
|
||||
<div className="min-w-0 rounded-lg border border-border bg-card lg:min-h-[520px]">
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
@ -340,7 +356,7 @@ export default function FilesPage() {
|
||||
<FolderOpen className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium">{pickAppText(locale, '加载失败', 'Failed to load')}</p>
|
||||
<p className="max-w-sm text-center text-sm">{loadError}</p>
|
||||
<Button className="mt-4" variant="outline" size="sm" onClick={() => load()}>
|
||||
<Button className="mt-4 h-11" variant="outline" size="sm" onClick={() => load()}>
|
||||
<RefreshCw className="mr-1 h-4 w-4" />
|
||||
{pickAppText(locale, '重试', 'Retry')}
|
||||
</Button>
|
||||
@ -349,18 +365,21 @@ export default function FilesPage() {
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<FolderOpen className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium">{pickAppText(locale, '空文件夹', 'Empty folder')}</p>
|
||||
<p className="text-sm">{pickAppText(locale, '点击上方"上传"或"新建文件夹"按钮开始使用', 'Use "Upload" or "New folder" above to get started')}</p>
|
||||
<p className="px-4 text-center text-sm">{pickAppText(locale, '点击上方"上传"或"新建文件夹"按钮开始使用', 'Use "Upload" or "New folder" above to get started')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[calc(100vh-15rem)] min-h-[520px]">
|
||||
<ScrollArea className="max-h-[calc(100vh-15rem)] min-h-[360px] lg:min-h-[520px]">
|
||||
<div className="space-y-1 p-2">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
<div
|
||||
key={item.path}
|
||||
type="button"
|
||||
className={`group flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors hover:bg-accent/30 ${
|
||||
className={`group flex min-w-0 flex-col gap-2 rounded-lg border p-2 text-left transition-colors hover:bg-accent/30 sm:flex-row sm:items-center ${
|
||||
selectedFile?.path === item.path ? 'border-primary bg-accent/40' : 'border-border bg-card'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-[3.5rem] min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-2 text-left"
|
||||
onClick={() => {
|
||||
if (item.type === 'directory') {
|
||||
navigateTo(item.path);
|
||||
@ -378,8 +397,8 @@ export default function FilesPage() {
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{item.name}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<div className={`text-sm font-medium ${containedLongTextClass}`}>{item.name}</div>
|
||||
<p className={`text-xs text-muted-foreground ${containedLongTextClass}`}>
|
||||
{item.type === 'file' && formatSize(item.size)}
|
||||
{item.modified && (
|
||||
<>
|
||||
@ -389,50 +408,37 @@ export default function FilesPage() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="flex shrink-0 items-center justify-end gap-1 opacity-100 md:opacity-0 md:transition-opacity md:group-hover:opacity-100">
|
||||
{item.type === 'file' && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent"
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-md hover:bg-accent"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDownload(item);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void handleDownload(item);
|
||||
}
|
||||
}}
|
||||
aria-label={`${pickAppText(locale, '下载', 'Download')} ${item.name}`}
|
||||
title={pickAppText(locale, '下载', 'Download')}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-destructive hover:bg-accent hover:text-destructive"
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-md text-destructive hover:bg-accent hover:text-destructive"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDelete(item);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void handleDelete(item);
|
||||
}
|
||||
}}
|
||||
aria-label={`${pickAppText(locale, '删除', 'Delete')} ${item.name}`}
|
||||
title={pickAppText(locale, '删除', 'Delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@ -471,7 +477,7 @@ function FilePreviewPanel({
|
||||
locale: AppLocale;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-[520px] rounded-lg border border-border bg-card p-4">
|
||||
<div className="min-w-0 rounded-lg border border-border bg-card p-4 lg:min-h-[520px]">
|
||||
{loading ? (
|
||||
<div className="flex h-[420px] items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
@ -485,16 +491,16 @@ function FilePreviewPanel({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="flex min-w-0 flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="break-all text-base font-semibold">{file.name}</h2>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<h2 className={`text-base font-semibold ${containedLongTextClass}`}>{file.name}</h2>
|
||||
<p className={`mt-1 text-xs text-muted-foreground ${containedLongTextClass}`}>
|
||||
{formatSize(file.size)} · {formatDate(file.modified)} · {file.content_type}
|
||||
{file.is_truncated ? ` · ${pickAppText(locale, '仅预览前 1MB', 'Showing first 1MB')}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{downloadUrl && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Button variant="outline" size="sm" className="h-11" asChild>
|
||||
<a href={downloadUrl}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '下载', 'Download')}
|
||||
@ -514,11 +520,11 @@ function FilePreviewPanel({
|
||||
<p className="text-sm font-medium">{pickAppText(locale, '该文件不能直接预览', 'This file cannot be previewed')}</p>
|
||||
</div>
|
||||
) : isMarkdown(file) ? (
|
||||
<div className="prose prose-sm max-h-[620px] max-w-none overflow-auto text-black prose-a:text-black prose-code:text-black prose-headings:text-black prose-li:text-black prose-p:text-black prose-pre:bg-muted prose-pre:text-black prose-strong:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
<div className={`prose prose-sm max-h-[620px] max-w-none overflow-auto text-black prose-a:text-black prose-code:text-black prose-headings:text-black prose-li:text-black prose-p:text-black prose-pre:bg-muted prose-pre:text-black prose-strong:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_*]:min-w-0 ${containedLongTextClass}`}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{file.content || ''}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="max-h-[620px] overflow-auto whitespace-pre-wrap rounded-md border border-border bg-background p-4 text-xs leading-5 text-black">
|
||||
<pre className={`max-h-[620px] overflow-auto rounded-md border border-border bg-background p-4 text-xs leading-5 text-black ${containedPreservedLongTextClass}`}>
|
||||
{file.content || ''}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
@ -168,10 +168,18 @@ export default function MarketplacePage() {
|
||||
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / 12)), [total]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl p-6">
|
||||
<div className="mx-auto mb-10 max-w-4xl">
|
||||
<div className="mx-auto max-w-7xl space-y-6 p-4 sm:p-6">
|
||||
<div className="mx-auto max-w-4xl space-y-5">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold tracking-normal sm:text-3xl">
|
||||
{t('市场', 'Marketplace')}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
{t('搜索、查看并安装 SkillHub 技能。', 'Search, inspect, and install SkillHub skills.')}
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
className="flex gap-3"
|
||||
className="flex flex-col gap-3 sm:flex-row"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setPage(0);
|
||||
@ -187,7 +195,7 @@ export default function MarketplacePage() {
|
||||
className="h-14 rounded-2xl pl-12 text-base"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="h-14 rounded-2xl px-10 text-base">
|
||||
<Button type="submit" className="h-14 rounded-2xl px-10 text-base sm:w-auto">
|
||||
{t('搜索', 'Search')}
|
||||
</Button>
|
||||
</form>
|
||||
@ -195,9 +203,9 @@ export default function MarketplacePage() {
|
||||
|
||||
{error && (
|
||||
<Card className="mb-6 border-destructive">
|
||||
<CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
<CardContent className="flex items-start gap-2 pt-6 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 break-words">{error}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@ -206,6 +214,7 @@ export default function MarketplacePage() {
|
||||
<div className="space-y-5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-11"
|
||||
onClick={() => {
|
||||
setSelected(null);
|
||||
setVersionDetail(null);
|
||||
@ -239,7 +248,7 @@ export default function MarketplacePage() {
|
||||
onOpenFile={(filePath) => void openFile(filePath)}
|
||||
badges={
|
||||
<>
|
||||
<Badge variant="outline">@{selected.namespace}</Badge>
|
||||
<Badge variant="outline" className="max-w-full break-all">@{selected.namespace}</Badge>
|
||||
<Badge variant="outline">{t('下载', 'Downloads')}: {selected.downloadCount || 0}</Badge>
|
||||
<Badge variant="outline">{t('收藏', 'Stars')}: {selected.starCount || 0}</Badge>
|
||||
{selected.installed && (
|
||||
@ -251,7 +260,7 @@ export default function MarketplacePage() {
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Button onClick={installSelected} disabled={installing || detailLoading || versionLoading}>
|
||||
<Button className="h-11" onClick={installSelected} disabled={installing || detailLoading || versionLoading}>
|
||||
{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
|
||||
{selected.installed ? t('重新安装/更新', 'Reinstall/update') : t('安装', 'Install')}
|
||||
</Button>
|
||||
@ -283,7 +292,7 @@ export default function MarketplacePage() {
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
<span className="ml-4 text-sm font-medium text-muted-foreground">{t('筛选:', 'Filter:')}</span>
|
||||
<span className="text-sm font-medium text-muted-foreground sm:ml-4">{t('筛选:', 'Filter:')}</span>
|
||||
<Button size="sm" variant={starredOnly ? 'default' : 'outline'} onClick={() => setStarredOnly((value) => !value)}>
|
||||
<Star className="mr-2 h-4 w-4" />
|
||||
{t('只看已收藏', 'Starred only')}
|
||||
@ -297,28 +306,34 @@ export default function MarketplacePage() {
|
||||
) : (
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<Card key={`${item.namespace}/${item.slug}`} className="cursor-pointer transition hover:border-primary" onClick={() => void openDetail(item)}>
|
||||
<Card key={`${item.namespace}/${item.slug}`} className="overflow-hidden transition hover:border-primary">
|
||||
<button
|
||||
type="button"
|
||||
className="block h-full w-full text-left"
|
||||
onClick={() => void openDetail(item)}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<CardTitle className="text-xl">{item.displayName || item.slug}</CardTitle>
|
||||
<Badge variant="outline">@{item.namespace}</Badge>
|
||||
<div className="flex flex-col items-start gap-3 sm:flex-row sm:justify-between">
|
||||
<CardTitle className="min-w-0 break-words text-xl">{item.displayName || item.slug}</CardTitle>
|
||||
<Badge variant="outline" className="max-w-full break-all">@{item.namespace}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<p className="line-clamp-3 min-h-[4.5rem] text-sm leading-6 text-muted-foreground">{item.summary}</p>
|
||||
<p className="line-clamp-3 min-h-[4.5rem] break-words text-sm leading-6 text-muted-foreground">{item.summary}</p>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
<Badge variant="secondary">v{publishedVersion(item) || '-'}</Badge>
|
||||
<Badge variant="secondary" className="max-w-full break-all">v{publishedVersion(item) || '-'}</Badge>
|
||||
<span>{item.downloadCount || 0}</span>
|
||||
<span>{item.starCount || 0}</span>
|
||||
{item.installed && <Badge variant="outline">{t('已安装', 'Installed')}</Badge>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<Button variant="outline" disabled={page <= 0} onClick={() => setPage((value) => Math.max(0, value - 1))}>
|
||||
{t('上一页', 'Previous')}
|
||||
</Button>
|
||||
|
||||
@ -247,6 +247,9 @@ export default function MCPPage() {
|
||||
};
|
||||
|
||||
const handleDelete = async (serverId: string) => {
|
||||
if (!window.confirm(t('确定删除这个 MCP 服务吗?此操作不可撤销。', 'Delete this MCP server? This action cannot be undone.'))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteMcpServer(serverId);
|
||||
setSelectedServerId((current) => (current === serverId ? null : current));
|
||||
@ -312,9 +315,9 @@ export default function MCPPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<div className="mx-auto max-w-6xl space-y-6 p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<ServerCog className="w-6 h-6" />
|
||||
{t('工具', 'Tools')}
|
||||
@ -323,8 +326,8 @@ export default function MCPPage() {
|
||||
{t('本地工具和在线工具都通过 MCP Server 暴露;本地工具按类别由真实 stdio MCP 子进程承载。', 'Local and online tools are both exposed through MCP servers. Local tool categories run as real stdio MCP subprocesses.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => void load(true)}>
|
||||
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:justify-end">
|
||||
<Button variant="outline" size="sm" className="h-11" onClick={() => void load(true)}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
{t('刷新', 'Refresh')}
|
||||
</Button>
|
||||
@ -333,12 +336,12 @@ export default function MCPPage() {
|
||||
if (!open) resetForm();
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Button size="sm" className="h-11">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t('新增工具服务', 'Add tool server')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? t('编辑 MCP 服务', 'Edit MCP server') : t('新增 MCP 服务', 'Add MCP server')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@ -346,11 +349,11 @@ export default function MCPPage() {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="id">ID</Label>
|
||||
<Input id="id" value={form.id} onChange={(e) => setForm((s) => ({ ...s, id: e.target.value }))} required disabled={!!editingId} />
|
||||
<Input id="id" className="h-11" value={form.id} onChange={(e) => setForm((s) => ({ ...s, id: e.target.value }))} required disabled={!!editingId} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tool_timeout">{t('工具超时', 'Tool timeout')}</Label>
|
||||
<Input id="tool_timeout" type="number" min="1" value={form.tool_timeout} onChange={(e) => setForm((s) => ({ ...s, tool_timeout: e.target.value }))} />
|
||||
<Input id="tool_timeout" className="h-11" type="number" min="1" value={form.tool_timeout} onChange={(e) => setForm((s) => ({ ...s, tool_timeout: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<Tabs
|
||||
@ -390,6 +393,7 @@ export default function MCPPage() {
|
||||
<Label htmlFor="url">{t('MCP Server 地址', 'MCP server URL')}</Label>
|
||||
<Input
|
||||
id="url"
|
||||
className="h-11"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
|
||||
placeholder="http://localhost:3001/mcp"
|
||||
@ -403,7 +407,7 @@ export default function MCPPage() {
|
||||
id="auth_mode"
|
||||
value={form.auth_mode}
|
||||
onChange={(e) => setForm((s) => ({ ...s, auth_mode: e.target.value }))}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="none">none</option>
|
||||
<option value="oauth_backend_token">oauth_backend_token</option>
|
||||
@ -452,6 +456,7 @@ export default function MCPPage() {
|
||||
<Label htmlFor="command">{t('命令', 'Command')}</Label>
|
||||
<Input
|
||||
id="command"
|
||||
className="h-11"
|
||||
value={form.command}
|
||||
onChange={(e) => setForm((s) => ({ ...s, command: e.target.value }))}
|
||||
placeholder="npx"
|
||||
@ -462,6 +467,7 @@ export default function MCPPage() {
|
||||
<Label htmlFor="args">{t('参数', 'Arguments')}</Label>
|
||||
<Input
|
||||
id="args"
|
||||
className="h-11"
|
||||
value={form.args}
|
||||
onChange={(e) => setForm((s) => ({ ...s, args: e.target.value }))}
|
||||
placeholder="-y @modelcontextprotocol/server-github"
|
||||
@ -470,11 +476,11 @@ export default function MCPPage() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
<div className="sticky bottom-0 -mx-6 -mb-6 flex justify-end gap-2 border-t bg-background px-6 py-4">
|
||||
<Button type="button" variant="outline" className="h-11" onClick={() => setDialogOpen(false)}>
|
||||
{t('取消', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
<Button type="submit" className="h-11" disabled={submitting}>
|
||||
{submitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
|
||||
{t('保存', 'Save')}
|
||||
</Button>
|
||||
@ -488,7 +494,7 @@ export default function MCPPage() {
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-destructive text-sm">
|
||||
<div className="flex items-center gap-2 break-words text-sm text-destructive">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
@ -500,9 +506,9 @@ export default function MCPPage() {
|
||||
setToolTab(value as 'local' | 'online');
|
||||
setSelectedServerId(null);
|
||||
}} className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="local">{t('本地工具', 'Local tools')}</TabsTrigger>
|
||||
<TabsTrigger value="online">{t('在线工具', 'Online tools')}</TabsTrigger>
|
||||
<TabsList className="h-auto min-h-11">
|
||||
<TabsTrigger value="local" className="h-11 px-4">{t('本地工具', 'Local tools')}</TabsTrigger>
|
||||
<TabsTrigger value="online" className="h-11 px-4">{t('在线工具', 'Online tools')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
@ -511,6 +517,12 @@ export default function MCPPage() {
|
||||
{visibleServers.map((server) => (
|
||||
<Card
|
||||
key={server.id}
|
||||
className={cn(
|
||||
'min-w-0 transition-colors',
|
||||
selectedServerId === server.id && 'border-primary bg-primary/5 shadow-sm'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setSelectedServerId(server.id)}
|
||||
@ -520,18 +532,15 @@ export default function MCPPage() {
|
||||
setSelectedServerId(server.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'cursor-pointer transition-colors',
|
||||
selectedServerId === server.id && 'border-primary bg-primary/5 shadow-sm'
|
||||
)}
|
||||
className="min-h-11 cursor-pointer rounded-t-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-base">{server.name}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono">{server.id}</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="break-words text-base">{server.name}</CardTitle>
|
||||
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">{server.id}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 sm:justify-end">
|
||||
<Badge variant="outline">{transportLabel(server.transport, locale)}</Badge>
|
||||
<Badge variant="secondary">{server.category || (server.kind === 'local' ? 'local' : 'online')}</Badge>
|
||||
{server.managed && <Badge variant="outline">{t('内置', 'Built-in')}</Badge>}
|
||||
@ -541,48 +550,40 @@ export default function MCPPage() {
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-3 text-sm">
|
||||
{server.url && <div><span className="font-medium">URL:</span> <span className="text-muted-foreground break-all">{server.url}</span></div>}
|
||||
{server.command && <div><span className="font-medium">{t('命令:', 'Command:')}</span> <span className="text-muted-foreground">{server.command} {(server.args || []).join(' ')}</span></div>}
|
||||
{server.auth_mode && server.auth_mode !== 'none' && <div><span className="font-medium">{t('鉴权:', 'Auth:')}</span> <span className="text-muted-foreground">{server.auth_mode}</span></div>}
|
||||
<CardContent className="space-y-3 pt-0 text-sm">
|
||||
{server.url && <div className="break-words"><span className="font-medium">URL:</span> <span className="break-all text-muted-foreground">{server.url}</span></div>}
|
||||
{server.command && <div className="break-words"><span className="font-medium">{t('命令:', 'Command:')}</span> <span className="break-all text-muted-foreground">{server.command} {(server.args || []).join(' ')}</span></div>}
|
||||
{server.auth_mode && server.auth_mode !== 'none' && <div className="break-words"><span className="font-medium">{t('鉴权:', 'Auth:')}</span> <span className="break-all text-muted-foreground">{server.auth_mode}</span></div>}
|
||||
{(server.auth_audience || server.auth_mode === 'oauth_backend_token') && (
|
||||
<div><span className="font-medium">Audience:</span> <span className="text-muted-foreground">{server.auth_audience || resolveAuthAudience(server.id)}</span></div>
|
||||
<div className="break-words"><span className="font-medium">Audience:</span> <span className="break-all text-muted-foreground">{server.auth_audience || resolveAuthAudience(server.id)}</span></div>
|
||||
)}
|
||||
{(server.auth_scopes || []).length > 0 && <div><span className="font-medium">Scopes:</span> <span className="text-muted-foreground break-all">{(server.auth_scopes || []).join(', ')}</span></div>}
|
||||
{(server.auth_scopes || []).length > 0 && <div className="break-words"><span className="font-medium">Scopes:</span> <span className="break-all text-muted-foreground">{(server.auth_scopes || []).join(', ')}</span></div>}
|
||||
{server.auth_mode === 'oauth_backend_token' && (!server.auth_scopes || server.auth_scopes.length === 0) && (
|
||||
<div><span className="font-medium">Scopes:</span> <span className="text-muted-foreground">{t('由 AuthZ 动态决定', 'Derived from AuthZ')}</span></div>
|
||||
<div className="break-words"><span className="font-medium">Scopes:</span> <span className="text-muted-foreground">{t('由 AuthZ 动态决定', 'Derived from AuthZ')}</span></div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{t(`${discoveredToolCount(server.id, tools, server.tool_count)} 个工具`, `${discoveredToolCount(server.id, tools, server.tool_count)} tools`)}</span>
|
||||
<span>{selectedServerId === server.id ? t('已选中', 'Selected') : t('点击查看工具', 'Click to view tools')}</span>
|
||||
{server.last_error && <span className="text-rose-300">{server.last_error}</span>}
|
||||
{server.last_error && <span className="break-all text-destructive">{server.last_error}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
</CardContent>
|
||||
</div>
|
||||
<CardContent className="flex flex-wrap items-center justify-end gap-2 pt-0">
|
||||
{!server.managed && (
|
||||
<Button variant="outline" size="sm" onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openEdit(server);
|
||||
}}>
|
||||
<Button variant="outline" size="sm" className="h-11" onClick={() => openEdit(server)}>
|
||||
{t('编辑', 'Edit')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleTest(server.id);
|
||||
}} disabled={testingId === server.id}>
|
||||
<Button variant="outline" size="sm" className="h-11" onClick={() => void handleTest(server.id)} disabled={testingId === server.id}>
|
||||
{testingId === server.id ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <TestTube2 className="w-4 h-4 mr-2" />}
|
||||
{t('测试', 'Test')}
|
||||
</Button>
|
||||
{!server.managed && (
|
||||
<Button variant="outline" size="sm" onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDelete(server.id);
|
||||
}}>
|
||||
<Button variant="outline" size="sm" className="h-11" onClick={() => void handleDelete(server.id)}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t('删除', 'Delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@ -595,9 +596,9 @@ export default function MCPPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Card className="min-w-0">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<CardTitle className="flex items-center gap-2 break-words text-base">
|
||||
<Wrench className="w-4 h-4" />
|
||||
{selectedServer ? t(`${selectedServer.name} 的工具`, `${selectedServer.name} tools`) : t('工具详情', 'Tool details')}
|
||||
</CardTitle>
|
||||
@ -613,12 +614,12 @@ export default function MCPPage() {
|
||||
)}
|
||||
{selectedToolGroup && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">{selectedToolGroup.server_id}</div>
|
||||
<div className="break-all text-sm font-medium">{selectedToolGroup.server_id}</div>
|
||||
<div className="space-y-2">
|
||||
{selectedToolGroup.tools.map((tool) => (
|
||||
<div key={String(tool.name)} className="rounded-md border border-border/70 px-3 py-2 bg-background/60">
|
||||
<div className="text-sm font-medium">{String(tool.tool_name || tool.name)}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap break-words">
|
||||
<div key={String(tool.name)} className="min-w-0 rounded-md border border-border/70 bg-background/60 px-3 py-2">
|
||||
<div className="break-all text-sm font-medium">{String(tool.tool_name || tool.name)}</div>
|
||||
<div className="mt-1 whitespace-pre-wrap break-words text-xs text-muted-foreground">
|
||||
{String(tool.description || '—')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -9,6 +9,7 @@ import { getNotification, sendMessage } from '@/lib/api';
|
||||
import type { ChatMessage, NotificationDetail } from '@/types';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { containedLongTextClass } from '@/lib/text-wrapping';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
|
||||
@ -27,6 +28,7 @@ export default function NotificationDetailPage() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const replyTextareaId = `notification-reply-${scheduledRunId}`;
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
@ -94,8 +96,8 @@ export default function NotificationDetailPage() {
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-6 py-8">
|
||||
<Link href="/notifications" className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||
<main className="mx-auto max-w-4xl px-4 py-6 sm:px-6 sm:py-8">
|
||||
<Link href="/notifications" className="inline-flex h-11 items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{pickAppText(locale, '返回通知', 'Back to notifications')}
|
||||
</Link>
|
||||
@ -106,29 +108,29 @@ export default function NotificationDetailPage() {
|
||||
|
||||
return (
|
||||
<main className="flex h-[calc(100vh-4rem)] flex-col bg-background">
|
||||
<div className="border-b border-[#E6E1DE] bg-[#F7F6F5] px-6 py-4">
|
||||
<div className="border-b border-[#E6E1DE] bg-[#F7F6F5] px-4 py-4 sm:px-6">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Link href="/notifications" className="mb-2 inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||
<Link href="/notifications" className="mb-2 inline-flex h-11 items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{pickAppText(locale, '通知列表', 'Notifications')}
|
||||
</Link>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1 className="truncate text-xl font-semibold">{detail.title || detail.job_name}</h1>
|
||||
<Badge variant={detail.status === 'error' ? 'destructive' : 'secondary'}>{detail.status}</Badge>
|
||||
{detail.engaged && <Badge>{pickAppText(locale, '已接入 Task', 'Task linked')}</Badge>}
|
||||
<h1 className={`min-w-0 max-w-full text-lg font-semibold sm:text-xl ${containedLongTextClass}`}>{detail.title || detail.job_name}</h1>
|
||||
<Badge variant={detail.status === 'error' ? 'destructive' : 'secondary'} className="shrink-0">{detail.status}</Badge>
|
||||
{detail.engaged && <Badge className="shrink-0">{pickAppText(locale, '已接入 Task', 'Task linked')}</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '生成时间', 'Generated')}: {formatTime(detail.started_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => void load()}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-11" onClick={() => void load()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '刷新', 'Refresh')}
|
||||
</Button>
|
||||
{detail.task_id && (
|
||||
<Button asChild size="sm">
|
||||
<Button asChild size="sm" className="h-11">
|
||||
<Link href={`/tasks/${encodeURIComponent(detail.task_id)}`}>{pickAppText(locale, '查看任务', 'Open task')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
@ -136,7 +138,7 @@ export default function NotificationDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="mx-auto w-full max-w-6xl px-6 pt-3 text-sm text-destructive">{error}</div>}
|
||||
{error && <div className={`mx-auto w-full max-w-6xl px-4 pt-3 text-sm text-destructive sm:px-6 ${containedLongTextClass}`}>{error}</div>}
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<ChatWorkbench
|
||||
@ -154,13 +156,15 @@ export default function NotificationDetailPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#E6E1DE] bg-background px-6 py-4">
|
||||
<div className="border-t border-[#E6E1DE] bg-background px-4 py-4 sm:px-6">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={intent === 'revise_once' ? 'default' : 'outline'}
|
||||
className="h-11"
|
||||
aria-pressed={intent === 'revise_once'}
|
||||
onClick={() => setIntent('revise_once')}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
@ -170,13 +174,15 @@ export default function NotificationDetailPage() {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={intent === 'update_future' ? 'default' : 'outline'}
|
||||
className="h-11"
|
||||
aria-pressed={intent === 'update_future'}
|
||||
onClick={() => setIntent('update_future')}
|
||||
>
|
||||
<Settings2 className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '以后按这样', 'Apply going forward')}
|
||||
</Button>
|
||||
{detail.engaged && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span className="inline-flex min-h-11 items-center gap-1 text-xs text-muted-foreground">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '这条通知已经接入 Task', 'This notification is linked to a Task')}
|
||||
</span>
|
||||
@ -184,7 +190,13 @@ export default function NotificationDetailPage() {
|
||||
</div>
|
||||
{intent && (
|
||||
<div className="rounded-[20px] border border-[#E6E1DE] bg-white p-3 shadow-[0_6px_18px_rgba(0,0,0,0.06)]">
|
||||
<label htmlFor={replyTextareaId} className="mb-2 block px-2 text-xs font-medium text-muted-foreground">
|
||||
{intent === 'update_future'
|
||||
? pickAppText(locale, '以后这类通知的调整说明', 'Future notification adjustment')
|
||||
: pickAppText(locale, '本次通知的修改说明', 'Revision note for this notification')}
|
||||
</label>
|
||||
<textarea
|
||||
id={replyTextareaId}
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
placeholder={
|
||||
@ -192,10 +204,10 @@ export default function NotificationDetailPage() {
|
||||
? pickAppText(locale, '告诉我以后这类通知要怎么调整...', 'Describe how future notifications should change...')
|
||||
: pickAppText(locale, '告诉我这次内容要怎么改...', 'Describe how this result should change...')
|
||||
}
|
||||
className="block min-h-20 w-full resize-none border-0 bg-transparent px-2 py-1 text-sm leading-6 outline-none"
|
||||
className={`block min-h-20 w-full resize-none border-0 bg-transparent px-2 py-1 text-sm leading-6 outline-none ${containedLongTextClass}`}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => void submitReply()} disabled={!input.trim() || submitting}>
|
||||
<Button size="sm" className="h-11" onClick={() => void submitReply()} disabled={!input.trim() || submitting}>
|
||||
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
||||
{pickAppText(locale, '发送', 'Send')}
|
||||
</Button>
|
||||
|
||||
@ -8,6 +8,7 @@ import { listNotifications } from '@/lib/api';
|
||||
import type { NotificationRun } from '@/types';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { containedLongTextClass } from '@/lib/text-wrapping';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@ -40,18 +41,18 @@ export default function NotificationsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex h-[calc(100vh-4rem)] max-w-6xl flex-col px-6 py-8">
|
||||
<main className="mx-auto flex h-[calc(100vh-4rem)] max-w-6xl flex-col px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<Bell className="h-5 w-5" />
|
||||
{pickAppText(locale, '通知', 'Notifications')}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '定时任务生成的日报、提醒和总结会固定出现在这里。', 'Scheduled reports, reminders, and summaries appear here.')}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => void load()} variant="outline" size="sm">
|
||||
<Button onClick={() => void load()} variant="outline" size="sm" className="h-11">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '刷新', 'Refresh')}
|
||||
</Button>
|
||||
@ -83,21 +84,21 @@ export default function NotificationsPage() {
|
||||
<Link
|
||||
key={item.scheduled_run_id}
|
||||
href={`/notifications/${encodeURIComponent(item.scheduled_run_id)}`}
|
||||
className="grid gap-3 px-5 py-4 transition-colors hover:bg-[#F7F6F5] md:grid-cols-[minmax(0,1fr)_180px_110px_24px]"
|
||||
className="grid min-w-0 gap-3 px-4 py-4 transition-colors hover:bg-[#F7F6F5] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset sm:px-5 md:grid-cols-[minmax(0,1fr)_180px_110px_24px]"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium">{item.title || item.job_name}</span>
|
||||
{item.engaged && <Badge variant="secondary">{pickAppText(locale, '已接入 Task', 'Task linked')}</Badge>}
|
||||
{item.status === 'error' && <Badge variant="destructive">{pickAppText(locale, '错误', 'Error')}</Badge>}
|
||||
<span className={`min-w-0 flex-1 font-medium ${containedLongTextClass}`}>{item.title || item.job_name}</span>
|
||||
{item.engaged && <Badge variant="secondary" className="shrink-0">{pickAppText(locale, '已接入 Task', 'Task linked')}</Badge>}
|
||||
{item.status === 'error' && <Badge variant="destructive" className="shrink-0">{pickAppText(locale, '错误', 'Error')}</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{item.output || item.message}</p>
|
||||
<p className={`mt-1 line-clamp-2 text-sm text-muted-foreground ${containedLongTextClass}`}>{item.output || item.message}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock3 className="h-4 w-4" />
|
||||
{formatTime(item.started_at)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{item.job_name}</div>
|
||||
<div className={`text-sm text-muted-foreground ${containedLongTextClass}`}>{item.job_name}</div>
|
||||
<ArrowRight className="hidden h-4 w-4 self-center text-muted-foreground md:block" />
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@ -318,7 +318,7 @@ function renderPlainText(content: string): React.ReactNode[] {
|
||||
href={part}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-primary underline underline-offset-2 break-all"
|
||||
className="inline-block min-h-11 max-w-full break-all py-2 text-primary underline underline-offset-2"
|
||||
>
|
||||
{part}
|
||||
</a>
|
||||
@ -461,7 +461,9 @@ export default function OutlookPage() {
|
||||
if (!background) {
|
||||
setStatusLoading(false);
|
||||
}
|
||||
if (!nextStatus.configured) {
|
||||
if (nextStatus.configured) {
|
||||
await loadOverview(options?.preserveOverview ?? background);
|
||||
} else {
|
||||
setOverview(null);
|
||||
setOverviewLoading(false);
|
||||
}
|
||||
@ -647,6 +649,9 @@ export default function OutlookPage() {
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
if (!window.confirm(t('确定断开 Outlook 连接吗?已保存的连接凭据会被移除。', 'Disconnect Outlook? Saved connection credentials will be removed.'))) {
|
||||
return;
|
||||
}
|
||||
setDisconnecting(true);
|
||||
setError(null);
|
||||
try {
|
||||
@ -671,9 +676,7 @@ export default function OutlookPage() {
|
||||
|
||||
const refreshOverview = async () => {
|
||||
await loadStatus(true, { preserveOverview: true });
|
||||
if (activeView === 'settings' && isConfigured) {
|
||||
await loadOverview(true);
|
||||
} else if (activeView === 'inbox') {
|
||||
if (activeView === 'inbox') {
|
||||
await loadMailboxPage('inbox', inboxPage?.page.skip ?? 0);
|
||||
} else if (activeView === 'sent') {
|
||||
await loadMailboxPage('sent', sentPage?.page.skip ?? 0);
|
||||
@ -684,14 +687,14 @@ export default function OutlookPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
<div className="mx-auto max-w-7xl space-y-6 p-6">
|
||||
<div className="mx-auto max-w-7xl space-y-6 p-4 sm:p-6">
|
||||
<section className="rounded-2xl border bg-card px-4 py-4 shadow-sm">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<div className="mr-2 flex items-center gap-2 text-lg font-semibold text-foreground">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 text-sm">
|
||||
<h1 className="mr-2 flex min-w-0 items-center gap-2 text-lg font-semibold text-foreground">
|
||||
<Mail className="h-5 w-5" />
|
||||
Outlook
|
||||
</div>
|
||||
</h1>
|
||||
{statusPending ? (
|
||||
<>
|
||||
<Skeleton className="h-6 w-20 rounded-full" />
|
||||
@ -710,9 +713,9 @@ export default function OutlookPage() {
|
||||
{status?.mcp_registered ? t('MCP 已注册', 'MCP registered') : t('MCP 未注册', 'MCP not registered')}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{status?.provider || 'ews'}</Badge>
|
||||
<span className="text-muted-foreground">{t('邮箱', 'Mailbox')} {overview?.mailbox || status?.saved?.email || '-'}</span>
|
||||
<span className="text-muted-foreground">{t('时区', 'Timezone')} {status?.saved?.default_timezone || overview?.timezone || form.default_timezone}</span>
|
||||
<span className="text-muted-foreground">
|
||||
<span className="min-w-0 break-all text-muted-foreground">{t('邮箱', 'Mailbox')} {overview?.mailbox || status?.saved?.email || '-'}</span>
|
||||
<span className="min-w-0 break-all text-muted-foreground">{t('时区', 'Timezone')} {status?.saved?.default_timezone || overview?.timezone || form.default_timezone}</span>
|
||||
<span className="min-w-0 break-words text-muted-foreground">
|
||||
{t('最近刷新', 'Last refreshed')} {formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined, locale)}
|
||||
</span>
|
||||
</>
|
||||
@ -727,7 +730,7 @@ export default function OutlookPage() {
|
||||
<TopStat label={t('日程', 'Calendar')} value={String(eventCount)} loading={overviewPending} />
|
||||
</>
|
||||
) : null}
|
||||
<Button variant="outline" size="sm" onClick={() => void refreshOverview()}>
|
||||
<Button variant="outline" size="sm" className="h-11" onClick={() => void refreshOverview()}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
{t('刷新', 'Refresh')}
|
||||
</Button>
|
||||
@ -740,7 +743,7 @@ export default function OutlookPage() {
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
<span className="min-w-0 flex-1 break-all">{error}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -752,7 +755,7 @@ export default function OutlookPage() {
|
||||
{overviewWarnings.map((warning, index) => (
|
||||
<div key={`${warning}-${index}`} className="flex items-start gap-3">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{warning}</span>
|
||||
<span className="min-w-0 flex-1 break-all">{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
@ -771,7 +774,7 @@ export default function OutlookPage() {
|
||||
<TabsTrigger
|
||||
key={view.id}
|
||||
value={view.id}
|
||||
className="h-auto rounded-xl border border-transparent px-4 py-3 data-[state=active]:border-border data-[state=active]:shadow-sm"
|
||||
className="min-h-11 rounded-xl border border-transparent px-4 py-3 data-[state=active]:border-border data-[state=active]:shadow-sm"
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
@ -872,53 +875,67 @@ export default function OutlookPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 pt-6">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label={t('邮箱地址', 'Email address')} required>
|
||||
<Field id="outlook-email" label={t('邮箱地址', 'Email address')} required>
|
||||
<Input
|
||||
id="outlook-email"
|
||||
className="h-11"
|
||||
value={form.email}
|
||||
onChange={(event) => updateField('email', event.target.value)}
|
||||
placeholder="you@boardware.com"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('用户名', 'Username')}>
|
||||
<Field id="outlook-username" label={t('用户名', 'Username')}>
|
||||
<Input
|
||||
id="outlook-username"
|
||||
className="h-11"
|
||||
value={form.username}
|
||||
onChange={(event) => updateField('username', event.target.value)}
|
||||
placeholder={t('留空时默认取邮箱前缀', 'Leave blank to default to the email prefix')}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('密码', 'Password')} required>
|
||||
<Field id="outlook-password" label={t('密码', 'Password')} required>
|
||||
<Input
|
||||
id="outlook-password"
|
||||
className="h-11"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(event) => updateField('password', event.target.value)}
|
||||
placeholder={t('请输入邮箱密码', 'Enter the mailbox password')}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('域', 'Domain')}>
|
||||
<Field id="outlook-domain" label={t('域', 'Domain')}>
|
||||
<Input
|
||||
id="outlook-domain"
|
||||
className="h-11"
|
||||
value={form.domain}
|
||||
onChange={(event) => updateField('domain', event.target.value)}
|
||||
placeholder="boardware.com.mo"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="EWS URL">
|
||||
<Field id="outlook-service-endpoint" label="EWS URL">
|
||||
<Input
|
||||
id="outlook-service-endpoint"
|
||||
className="h-11"
|
||||
value={form.service_endpoint}
|
||||
onChange={(event) => updateField('service_endpoint', event.target.value)}
|
||||
placeholder="https://mail.boardware.com.mo/EWS/Exchange.asmx"
|
||||
disabled={form.autodiscover}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Server Host">
|
||||
<Field id="outlook-server" label="Server Host">
|
||||
<Input
|
||||
id="outlook-server"
|
||||
className="h-11"
|
||||
value={form.server}
|
||||
onChange={(event) => updateField('server', event.target.value)}
|
||||
placeholder="mail.boardware.com.mo"
|
||||
disabled={form.autodiscover}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('时区', 'Timezone')}>
|
||||
<Field id="outlook-timezone" label={t('时区', 'Timezone')}>
|
||||
<Input
|
||||
id="outlook-timezone"
|
||||
className="h-11"
|
||||
value={form.default_timezone}
|
||||
onChange={(event) => updateField('default_timezone', event.target.value)}
|
||||
placeholder="Asia/Shanghai"
|
||||
@ -944,16 +961,17 @@ export default function OutlookPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleTest} disabled={!canTest || testing}>
|
||||
<Button variant="outline" className="h-11" onClick={handleTest} disabled={!canTest || testing}>
|
||||
{testing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
|
||||
{t('测试连接', 'Test connection')}
|
||||
</Button>
|
||||
<Button onClick={handleConnect} disabled={!canTest || saving}>
|
||||
<Button className="h-11" onClick={handleConnect} disabled={!canTest || saving}>
|
||||
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
||||
{t('保存并启用', 'Save and enable')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-11"
|
||||
onClick={handleDisconnect}
|
||||
disabled={!status?.configured || disconnecting}
|
||||
>
|
||||
@ -966,8 +984,8 @@ export default function OutlookPage() {
|
||||
<div className="rounded-3xl border bg-muted/30 p-4 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="default">{t('测试成功', 'Test succeeded')}</Badge>
|
||||
<span className="text-muted-foreground">{testResult.mailbox}</span>
|
||||
<span className="text-muted-foreground">{t('用户名', 'Username')}: {testResult.resolved_username}</span>
|
||||
<span className="break-all text-muted-foreground">{testResult.mailbox}</span>
|
||||
<span className="break-all text-muted-foreground">{t('用户名', 'Username')}: {testResult.resolved_username}</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
||||
<MiniStat label={t('检测到文件夹', 'Detected folders')} value={String(testResult.sample.folders.length)} />
|
||||
@ -979,7 +997,7 @@ export default function OutlookPage() {
|
||||
{testWarnings.map((warning, index) => (
|
||||
<div key={`${warning}-${index}`} className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{warning}</span>
|
||||
<span className="min-w-0 flex-1 break-all">{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -1054,10 +1072,10 @@ export default function OutlookPage() {
|
||||
</Tabs>
|
||||
|
||||
<Dialog open={Boolean(selectedMessageRef)} onOpenChange={(open) => !open && setSelectedMessageRef(null)}>
|
||||
<DialogContent className="sm:max-w-5xl">
|
||||
<DialogContent className="bottom-4 left-4 right-4 top-4 max-h-none w-auto max-w-none translate-x-0 translate-y-0 overflow-y-auto data-[state=open]:slide-in-from-left-0 data-[state=open]:slide-in-from-top-0 data-[state=closed]:slide-out-to-left-0 data-[state=closed]:slide-out-to-top-0 sm:bottom-auto sm:left-[50%] sm:right-auto sm:top-[50%] sm:max-h-[calc(100dvh-2rem)] sm:w-[calc(100vw-2rem)] sm:max-w-5xl sm:translate-x-[-50%] sm:translate-y-[-50%] sm:data-[state=open]:slide-in-from-left-1/2 sm:data-[state=open]:slide-in-from-top-[48%] sm:data-[state=closed]:slide-out-to-left-1/2 sm:data-[state=closed]:slide-out-to-top-[48%]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedMessage?.subject || t('邮件详情', 'Message details')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<DialogTitle className="break-words pr-8 leading-6">{selectedMessage?.subject || t('邮件详情', 'Message details')}</DialogTitle>
|
||||
<DialogDescription className="break-words">
|
||||
{selectedMessage?.receivedDateTime ? formatDateTime(selectedMessage.receivedDateTime, locale) : t('正在加载', 'Loading')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@ -1066,7 +1084,7 @@ export default function OutlookPage() {
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : selectedMessage ? (
|
||||
<div className="grid gap-4 lg:grid-cols-[280px,1fr]">
|
||||
<div className="grid min-w-0 gap-4 lg:grid-cols-[280px,1fr]">
|
||||
<div className="space-y-4 rounded-2xl border bg-muted/20 p-4 text-sm">
|
||||
<InfoRow label={t('发件人', 'From')} value={mailboxLabel(selectedMessage.from)} />
|
||||
<InfoRow
|
||||
@ -1085,7 +1103,7 @@ export default function OutlookPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border bg-background">
|
||||
<div className="min-w-0 overflow-hidden rounded-2xl border bg-background">
|
||||
<div className="border-b px-4 py-3 text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{t('正文', 'Body')}
|
||||
</div>
|
||||
@ -1115,10 +1133,10 @@ export default function OutlookPage() {
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(selectedEvent)} onOpenChange={(open) => !open && setSelectedEvent(null)}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogContent className="bottom-4 left-4 right-4 top-4 max-h-none w-auto max-w-none translate-x-0 translate-y-0 overflow-y-auto data-[state=open]:slide-in-from-left-0 data-[state=open]:slide-in-from-top-0 data-[state=closed]:slide-out-to-left-0 data-[state=closed]:slide-out-to-top-0 sm:bottom-auto sm:left-[50%] sm:right-auto sm:top-[50%] sm:max-h-[calc(100dvh-2rem)] sm:w-[calc(100vw-2rem)] sm:max-w-2xl sm:translate-x-[-50%] sm:translate-y-[-50%] sm:data-[state=open]:slide-in-from-left-1/2 sm:data-[state=open]:slide-in-from-top-[48%] sm:data-[state=closed]:slide-out-to-left-1/2 sm:data-[state=closed]:slide-out-to-top-[48%]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedEvent?.subject || t('日程详情', 'Event details')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<DialogTitle className="break-words pr-8 leading-6">{selectedEvent?.subject || t('日程详情', 'Event details')}</DialogTitle>
|
||||
<DialogDescription className="break-words">
|
||||
{selectedEvent
|
||||
? `${formatDateTime(selectedEvent.start?.dateTime, locale)} - ${formatDateTime(selectedEvent.end?.dateTime, locale)}`
|
||||
: t('日程详情', 'Event details')}
|
||||
@ -1135,7 +1153,7 @@ export default function OutlookPage() {
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{t('说明', 'Notes')}</p>
|
||||
<div className="rounded-lg border bg-muted/40 p-3 whitespace-pre-wrap">
|
||||
<div className="rounded-lg border bg-muted/40 p-3 whitespace-pre-wrap break-words">
|
||||
{selectedEvent.bodyPreview || t('没有更多说明。', 'No additional notes.')}
|
||||
</div>
|
||||
</div>
|
||||
@ -1149,17 +1167,19 @@ export default function OutlookPage() {
|
||||
}
|
||||
|
||||
function Field({
|
||||
id,
|
||||
label,
|
||||
required = false,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
<Label htmlFor={id} className="text-sm font-medium">
|
||||
{label}
|
||||
{required ? <span className="ml-1 text-destructive">*</span> : null}
|
||||
</Label>
|
||||
@ -1235,25 +1255,26 @@ function MessageCard({
|
||||
const pageLabel = page ? t(`第 ${currentPage} 页 · 本页 ${page.returned} 封`, `Page ${currentPage} · ${page.returned} messages`) : t('正在读取邮件…', 'Loading messages...');
|
||||
|
||||
return (
|
||||
<Card className="rounded-[28px] shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4 border-b pb-5">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Card className="min-w-0 rounded-[28px] shadow-sm">
|
||||
<CardHeader className="flex flex-col gap-4 border-b pb-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<CardTitle className="flex items-center gap-2 break-words text-base">
|
||||
{icon}
|
||||
{title}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{loading ? t('正在读取邮件…', 'Loading messages...') : pageLabel}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-11 w-11 p-0" aria-label={t('刷新邮件', 'Refresh mail')} title={t('刷新邮件', 'Refresh mail')} onClick={onRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onPreviousPage} disabled={!page || page.skip === 0 || refreshing}>
|
||||
<Button variant="outline" size="sm" className="h-11" onClick={onPreviousPage} disabled={!page || page.skip === 0 || refreshing}>
|
||||
{t('上一页', 'Previous')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-11"
|
||||
onClick={onNextPage}
|
||||
disabled={!page || !page.has_more || refreshing}
|
||||
>
|
||||
@ -1284,17 +1305,17 @@ function MessageCard({
|
||||
key={item.id || `${item.subject}-${item.receivedDateTime}`}
|
||||
type="button"
|
||||
onClick={() => item.id && onOpen(item)}
|
||||
className="w-full rounded-2xl border bg-card p-4 text-left transition-colors hover:bg-muted/40"
|
||||
className="min-h-11 w-full rounded-2xl border bg-card p-4 text-left transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-foreground">{item.subject || t('(无主题)', '(No subject)')}</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">{mailboxLabel(item.from)}</p>
|
||||
<p className="mt-3 line-clamp-2 text-sm leading-6 text-muted-foreground">
|
||||
<p className="break-words font-medium text-foreground">{item.subject || t('(无主题)', '(No subject)')}</p>
|
||||
<p className="mt-1 break-all text-xs text-muted-foreground">{mailboxLabel(item.from)}</p>
|
||||
<p className="mt-3 line-clamp-2 break-words text-sm leading-6 text-muted-foreground">
|
||||
{item.bodyPreview || t('没有预览内容。', 'No preview available.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 lg:flex-col lg:items-end">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 lg:flex-col lg:items-end">
|
||||
<Badge variant={item.isRead ? 'secondary' : 'default'}>
|
||||
{item.isRead ? t('已读', 'Read') : t('未读', 'Unread')}
|
||||
</Badge>
|
||||
@ -1357,10 +1378,10 @@ function EventCard({
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="rounded-[28px] shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 border-b pb-5">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Card className="min-w-0 rounded-[28px] shadow-sm">
|
||||
<CardHeader className="flex flex-col gap-4 space-y-0 border-b pb-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<CardTitle className="flex items-center gap-2 break-words text-base">
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
{t('日程安排', 'Schedule')}
|
||||
</CardTitle>
|
||||
@ -1368,17 +1389,17 @@ function EventCard({
|
||||
{formatDayLabel(weekDays[0], locale)} - {formatDayLabel(weekDays[weekDays.length - 1], locale)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onPreviousWeek} disabled={refreshing}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-11" onClick={onPreviousWeek} disabled={refreshing}>
|
||||
{t('上一周', 'Previous week')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onCurrentWeek} disabled={refreshing}>
|
||||
<Button variant="outline" size="sm" className="h-11" onClick={onCurrentWeek} disabled={refreshing}>
|
||||
{t('本周', 'This week')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onNextWeek} disabled={refreshing}>
|
||||
<Button variant="outline" size="sm" className="h-11" onClick={onNextWeek} disabled={refreshing}>
|
||||
{t('下一周', 'Next week')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
|
||||
<Button variant="ghost" size="sm" className="h-11 w-11 p-0" aria-label={t('刷新日程', 'Refresh calendar')} title={t('刷新日程', 'Refresh calendar')} onClick={onRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
@ -1397,7 +1418,7 @@ function EventCard({
|
||||
) : (
|
||||
<div className="grid gap-3 lg:grid-cols-2 2xl:grid-cols-3">
|
||||
{eventsByDay.map((day) => (
|
||||
<div key={day.key} className="rounded-2xl border bg-card p-4">
|
||||
<div key={day.key} className="min-w-0 rounded-2xl border bg-card p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{day.label}</p>
|
||||
@ -1413,13 +1434,13 @@ function EventCard({
|
||||
key={item.id || `${item.subject}-${item.start?.dateTime}`}
|
||||
type="button"
|
||||
onClick={() => onOpen(item)}
|
||||
className="w-full rounded-xl border bg-background p-3 text-left transition-colors hover:bg-muted/40"
|
||||
className="min-h-11 w-full rounded-xl border bg-background p-3 text-left transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<p className="font-medium text-foreground">{item.subject || t('(无主题)', '(No subject)')}</p>
|
||||
<p className="break-words font-medium text-foreground">{item.subject || t('(无主题)', '(No subject)')}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{formatTime(item.start?.dateTime, locale)} - {formatTime(item.end?.dateTime, locale)}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<p className="mt-2 break-words text-sm text-muted-foreground">
|
||||
{item.location?.displayName || t('未设置地点', 'No location set')}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
@ -2,15 +2,24 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Brain, Plus, Send, Trash2, X } from 'lucide-react';
|
||||
import { Brain, Menu, Plus, Send, Trash2, X } from 'lucide-react';
|
||||
|
||||
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
|
||||
import { CurrentSessionProgressSidebar } from '@/components/chat-workbench/CurrentSessionProgressSidebar';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
archiveSession,
|
||||
createSession,
|
||||
getActiveTask,
|
||||
getBackendTask,
|
||||
getSession,
|
||||
getSessionProcess,
|
||||
listSessions,
|
||||
@ -27,9 +36,9 @@ import {
|
||||
} from '@/lib/chat-messages';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { buildSessionProgressView } from '@/lib/session-progress';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import type { ActiveTask, ChatMessage, FileAttachment, SessionUpdatedEvent, WsEvent } from '@/types';
|
||||
import { buildTaskTimelineView } from '@/lib/task-timeline-view';
|
||||
import type { ActiveTask, BackendTask, ChatMessage, FileAttachment, SessionUpdatedEvent, WsEvent } from '@/types';
|
||||
|
||||
function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is SessionUpdatedEvent {
|
||||
return data.type === 'session_updated' && typeof data.session_id === 'string';
|
||||
@ -86,13 +95,17 @@ export default function ChatPage() {
|
||||
const [thinkingModeEnabled, setThinkingModeEnabled] = useState(loadThinkingModePreference);
|
||||
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
|
||||
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
|
||||
const [activeTaskDetail, setActiveTaskDetail] = useState<BackendTask | null>(null);
|
||||
const [revisionTargetRunId, setRevisionTargetRunId] = useState<string | null>(null);
|
||||
const [documentHidden, setDocumentHidden] = useState(isDocumentHidden);
|
||||
const [sessionDrawerOpen, setSessionDrawerOpen] = useState(false);
|
||||
const [archiveTargetSessionId, setArchiveTargetSessionId] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messageViewportRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const loadSessionReqSeq = useRef(0);
|
||||
const loadActiveTaskReqSeq = useRef(0);
|
||||
const loadedSessionIdRef = useRef<string | null>(null);
|
||||
const refreshSessionOnReconnectRef = useRef(false);
|
||||
const hasConnectedRef = useRef(false);
|
||||
@ -120,16 +133,15 @@ export default function ChatPage() {
|
||||
);
|
||||
|
||||
const selectedSessionRunId = selectedRunId && sessionRunIds.has(selectedRunId) ? selectedRunId : null;
|
||||
const sessionProgressView = useMemo(
|
||||
const activeTaskTimelineView = useMemo(
|
||||
() =>
|
||||
buildSessionProgressView({
|
||||
sessionId,
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
locale,
|
||||
buildTaskTimelineView({
|
||||
task: activeTaskDetail,
|
||||
liveRuns: processRuns,
|
||||
liveEvents: processEvents,
|
||||
liveArtifacts: processArtifacts,
|
||||
}),
|
||||
[locale, processArtifacts, processEvents, processRuns, sessionId]
|
||||
[activeTaskDetail, processArtifacts, processEvents, processRuns]
|
||||
);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
@ -142,12 +154,34 @@ export default function ChatPage() {
|
||||
}, []);
|
||||
|
||||
const loadActiveTask = useCallback(async (key: string) => {
|
||||
const reqSeq = ++loadActiveTaskReqSeq.current;
|
||||
try {
|
||||
if (useChatStore.getState().sessionId !== key) return;
|
||||
setActiveTask(await getActiveTask(key));
|
||||
} catch {
|
||||
if (useChatStore.getState().sessionId === key) {
|
||||
const nextActiveTask = await getActiveTask(key);
|
||||
if (reqSeq !== loadActiveTaskReqSeq.current || useChatStore.getState().sessionId !== key) return;
|
||||
setActiveTask(nextActiveTask);
|
||||
if (!nextActiveTask) {
|
||||
setActiveTaskDetail(null);
|
||||
return;
|
||||
}
|
||||
setActiveTaskDetail((current) => (current?.task_id === nextActiveTask.task_id ? current : null));
|
||||
try {
|
||||
const detail = await getBackendTask(nextActiveTask.task_id);
|
||||
if (reqSeq !== loadActiveTaskReqSeq.current || useChatStore.getState().sessionId !== key) return;
|
||||
if (detail.is_open === false) {
|
||||
setActiveTask(null);
|
||||
setActiveTaskDetail(null);
|
||||
return;
|
||||
}
|
||||
setActiveTaskDetail(detail);
|
||||
} catch {
|
||||
if (reqSeq === loadActiveTaskReqSeq.current && useChatStore.getState().sessionId === key) {
|
||||
setActiveTaskDetail(null);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (reqSeq === loadActiveTaskReqSeq.current && useChatStore.getState().sessionId === key) {
|
||||
setActiveTask(null);
|
||||
setActiveTaskDetail(null);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
@ -194,6 +228,7 @@ export default function ChatPage() {
|
||||
setIsThinking(false);
|
||||
}
|
||||
setActiveTask(null);
|
||||
setActiveTaskDetail(null);
|
||||
setRevisionTargetRunId(null);
|
||||
setInput(useChatStore.getState().getInputDraft(sessionId));
|
||||
void loadSessionMessages(sessionId);
|
||||
@ -299,6 +334,7 @@ export default function ChatPage() {
|
||||
|
||||
useEffect(() => {
|
||||
shouldSnapToLatestRef.current = true;
|
||||
setSessionDrawerOpen(false);
|
||||
}, [sessionId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@ -474,6 +510,7 @@ export default function ChatPage() {
|
||||
setSessionId(id);
|
||||
setSelectedRunId(null);
|
||||
setActiveTask(null);
|
||||
setActiveTaskDetail(null);
|
||||
setRevisionTargetRunId(null);
|
||||
clearInputDraft(id);
|
||||
setInput('');
|
||||
@ -487,14 +524,15 @@ export default function ChatPage() {
|
||||
void loadSessions();
|
||||
};
|
||||
|
||||
const handleArchiveSession = async (key: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const handleArchiveSession = async (key: string) => {
|
||||
try {
|
||||
await archiveSession(key);
|
||||
setArchiveTargetSessionId(null);
|
||||
useChatStore.getState().setSessions(useChatStore.getState().sessions.filter((session) => session.key !== key));
|
||||
if (key === sessionId) {
|
||||
setSessionId('web:default');
|
||||
setActiveTask(null);
|
||||
setActiveTaskDetail(null);
|
||||
setRevisionTargetRunId(null);
|
||||
clearInputDraft(key);
|
||||
setInput(useChatStore.getState().getInputDraft('web:default'));
|
||||
@ -514,9 +552,11 @@ export default function ChatPage() {
|
||||
const handleSelectSession = (key: string) => {
|
||||
setSelectedRunId(null);
|
||||
setActiveTask(null);
|
||||
setActiveTaskDetail(null);
|
||||
setRevisionTargetRunId(null);
|
||||
setInput(useChatStore.getState().getInputDraft(key));
|
||||
setSessionId(key);
|
||||
setSessionDrawerOpen(false);
|
||||
};
|
||||
|
||||
const removePendingFile = useCallback((file: File) => {
|
||||
@ -551,13 +591,17 @@ export default function ChatPage() {
|
||||
return key;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] bg-background">
|
||||
<aside className="flex w-[280px] shrink-0 flex-col border-r border-[#E6E1DE] bg-[#F7F6F5]">
|
||||
const archiveTargetSessionName = archiveTargetSessionId ? formatSessionName(archiveTargetSessionId) : '';
|
||||
|
||||
const renderSessionSidebar = (variant: 'desktop' | 'drawer') => (
|
||||
<>
|
||||
<div className="px-5 pb-5 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewSession}
|
||||
onClick={() => {
|
||||
setSessionDrawerOpen(false);
|
||||
void handleNewSession();
|
||||
}}
|
||||
className="flex h-11 w-full items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-[#342E2B]"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@ -570,34 +614,78 @@ export default function ChatPage() {
|
||||
{sessions.length === 0 && (
|
||||
<p className="px-3 py-4 text-sm text-muted-foreground">{pickAppText(locale, '暂无对话记录', 'No chat history yet')}</p>
|
||||
)}
|
||||
{sessions.map((session) => (
|
||||
{sessions.map((session) => {
|
||||
const sessionName = formatSessionName(session.key);
|
||||
const isCurrent = session.key === sessionId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.key}
|
||||
onClick={() => handleSelectSession(session.key)}
|
||||
className={`group flex cursor-pointer items-center justify-between rounded-xl px-4 py-3 text-[15px] transition-colors ${
|
||||
session.key === sessionId
|
||||
key={`${variant}:${session.key}`}
|
||||
className={`group flex items-center gap-1 rounded-xl px-2 py-1 text-[15px] transition-colors ${
|
||||
isCurrent
|
||||
? 'bg-[#EFEEED] text-foreground'
|
||||
: 'text-foreground hover:bg-[#EFEEED]/70'
|
||||
: 'text-foreground hover:bg-[#EFEEED]/70 focus-within:bg-[#EFEEED]/70'
|
||||
}`}
|
||||
>
|
||||
<div className="truncate">
|
||||
<span className="truncate">{formatSessionName(session.key)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(event) => handleArchiveSession(session.key, event)}
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-opacity"
|
||||
title={pickAppText(locale, '归档会话', 'Archive session')}
|
||||
aria-label={pickAppText(locale, '归档会话', 'Archive session')}
|
||||
type="button"
|
||||
onClick={() => handleSelectSession(session.key)}
|
||||
className="flex h-11 min-w-0 flex-1 items-center rounded-lg px-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<span className="truncate">{sessionName}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setArchiveTargetSessionId(session.key)}
|
||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-muted-foreground opacity-100 transition-colors hover:bg-white hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100"
|
||||
title={pickAppText(locale, '归档会话', 'Archive session')}
|
||||
aria-label={pickAppText(locale, `归档会话 ${sessionName}`, `Archive session ${sessionName}`)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-[calc(100dvh-4rem)] overflow-hidden bg-background">
|
||||
<aside className="hidden w-[280px] shrink-0 flex-col border-r border-[#E6E1DE] bg-[#F7F6F5] md:flex">
|
||||
{renderSessionSidebar('desktop')}
|
||||
</aside>
|
||||
|
||||
{sessionDrawerOpen && (
|
||||
<div className="fixed inset-x-0 bottom-0 top-16 z-40 md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-black/30"
|
||||
aria-label={pickAppText(locale, '关闭最近对话', 'Close recent chats')}
|
||||
onClick={() => setSessionDrawerOpen(false)}
|
||||
/>
|
||||
<aside className="absolute bottom-0 left-0 top-0 flex w-[min(86vw,320px)] flex-col border-r border-[#E6E1DE] bg-[#F7F6F5] shadow-2xl">
|
||||
{renderSessionSidebar('drawer')}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex min-h-14 items-center gap-2 border-b border-[#E6E1DE] bg-[#F7F6F5] px-3 md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSessionDrawerOpen(true)}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full border border-[#E6E1DE] bg-white text-[#1D1715]"
|
||||
aria-label={pickAppText(locale, '打开最近对话', 'Open recent chats')}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="min-w-0 text-sm font-medium text-foreground">
|
||||
<span className="block truncate">{formatSessionName(sessionId)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<ChatWorkbench
|
||||
messages={messages}
|
||||
@ -614,14 +702,14 @@ export default function ChatPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-background px-8 pb-8 pt-4">
|
||||
<div className="bg-background px-3 pb-4 pt-3 sm:px-5 sm:pb-6 md:px-8 md:pb-8 md:pt-4">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
{(activeTask || revisionTargetRunId) && (
|
||||
<div className="mb-2 flex">
|
||||
{activeTask ? (
|
||||
<Link
|
||||
href={`/tasks/${encodeURIComponent(activeTask.task_id)}`}
|
||||
className="inline-flex max-w-full items-center gap-2 rounded-full border border-[#D8D2CE] bg-[#F7F6F5] px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-[#EFEEED]"
|
||||
className="inline-flex h-11 max-w-full items-center gap-2 rounded-full border border-[#D8D2CE] bg-[#F7F6F5] px-3 text-xs text-foreground transition-colors hover:bg-[#EFEEED]"
|
||||
title={activeTask.description}
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
@ -638,7 +726,7 @@ export default function ChatPage() {
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="mb-2 space-y-1">
|
||||
{pendingFiles.map((item, index) => (
|
||||
<div key={`${item.file.name}:${index}`} className="flex items-center gap-2 px-3 py-1.5 bg-muted rounded-md text-sm">
|
||||
<div key={`${item.file.name}:${index}`} className="flex min-h-11 items-center gap-2 rounded-md bg-muted px-3 py-1.5 text-sm">
|
||||
<span className="truncate flex-1">
|
||||
{item.file.name}{' '}
|
||||
<span className="text-muted-foreground">({(item.file.size / 1024).toFixed(0)}KB)</span>
|
||||
@ -652,8 +740,13 @@ export default function ChatPage() {
|
||||
) : (
|
||||
<span className="text-[#657162] text-xs">{pickAppText(locale, '就绪', 'Ready')}</span>
|
||||
)}
|
||||
<button onClick={() => removePendingFile(item.file)} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePendingFile(item.file)}
|
||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-background hover:text-foreground"
|
||||
aria-label={pickAppText(locale, `移除附件 ${item.file.name}`, `Remove attachment ${item.file.name}`)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@ -662,8 +755,14 @@ export default function ChatPage() {
|
||||
|
||||
<div className="relative rounded-[28px] border border-[#E6E1DE] bg-white p-4 shadow-[0_8px_24px_rgba(0,0,0,0.08)]">
|
||||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
|
||||
<label htmlFor="chat-composer" className="sr-only">
|
||||
{revisionTargetRunId
|
||||
? pickAppText(locale, '修改要求', 'Revision request')
|
||||
: pickAppText(locale, '消息内容', 'Message content')}
|
||||
</label>
|
||||
|
||||
<textarea
|
||||
id="chat-composer"
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
@ -677,7 +776,7 @@ export default function ChatPage() {
|
||||
: pickAppText(locale, '今天想聊什么?', 'What would you like to talk about today?')
|
||||
}
|
||||
rows={1}
|
||||
className="block w-full resize-none border-0 bg-transparent px-2 pb-8 pt-1 text-[17px] leading-7 placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="block w-full resize-none border-0 bg-transparent px-1 pb-8 pt-1 text-[16px] leading-7 placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 sm:px-2 sm:text-[17px]"
|
||||
style={{ minHeight: '72px', maxHeight: '200px' }}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
@ -687,18 +786,20 @@ export default function ChatPage() {
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-5 text-[15px] text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-[15px] text-muted-foreground sm:gap-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="inline-flex items-center gap-2 text-foreground transition-colors hover:text-muted-foreground"
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full text-foreground transition-colors hover:bg-[#F7F5F4] hover:text-muted-foreground"
|
||||
title={pickAppText(locale, '添加附件', 'Add attachment')}
|
||||
aria-label={pickAppText(locale, '添加附件', 'Add attachment')}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleThinkingMode}
|
||||
className={`inline-flex h-8 items-center gap-2 rounded-full border px-3 text-sm transition-colors ${
|
||||
className={`inline-flex h-11 items-center gap-2 rounded-full border px-3 text-sm transition-colors ${
|
||||
thinkingModeEnabled
|
||||
? 'border-primary/40 bg-[#F1EFEE] text-foreground'
|
||||
: 'border-[#E6E1DE] bg-white text-muted-foreground hover:text-foreground'
|
||||
@ -729,7 +830,43 @@ export default function ChatPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sessionProgressView && <CurrentSessionProgressSidebar view={sessionProgressView} />}
|
||||
<Dialog open={Boolean(archiveTargetSessionId)} onOpenChange={(open) => !open && setArchiveTargetSessionId(null)}>
|
||||
<DialogContent className="max-w-[calc(100vw-2rem)] sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{pickAppText(locale, '归档此会话?', 'Archive this chat?')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pickAppText(
|
||||
locale,
|
||||
archiveTargetSessionName ? `会话「${archiveTargetSessionName}」会从最近对话中移除。` : '此会话会从最近对话中移除。',
|
||||
archiveTargetSessionName ? `Chat "${archiveTargetSessionName}" will be removed from recent chats.` : 'This chat will be removed from recent chats.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setArchiveTargetSessionId(null)}
|
||||
className="h-11 rounded-md border border-border px-4 text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
{pickAppText(locale, '取消', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => archiveTargetSessionId && void handleArchiveSession(archiveTargetSessionId)}
|
||||
className="h-11 rounded-md bg-destructive px-4 text-sm font-medium text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{pickAppText(locale, '确认归档', 'Confirm archive')}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{activeTaskDetail ? (
|
||||
<CurrentSessionProgressSidebar
|
||||
cards={activeTaskTimelineView?.cards ?? []}
|
||||
isLive={Boolean(activeTaskDetail.is_open && wsStatus === 'connected')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
@ -77,13 +78,25 @@ import { containedJsonTextClass, containedLongTextClass } from '@/lib/text-wrapp
|
||||
|
||||
const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']);
|
||||
const REJECTABLE_DRAFT_STATUSES = new Set(['draft', 'in_review', 'approved']);
|
||||
type SkillsTab = 'published' | 'candidates' | 'drafts';
|
||||
|
||||
function normalizeSkillsTab(value: string | null | undefined): SkillsTab {
|
||||
if (value === 'candidates' || value === 'drafts') {
|
||||
return value;
|
||||
}
|
||||
return 'published';
|
||||
}
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [candidates, setCandidates] = useState<SkillLearningCandidate[]>([]);
|
||||
const [drafts, setDrafts] = useState<SkillDraft[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<SkillsTab>(() => normalizeSkillsTab(searchParams?.get('tab')));
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionId, setActionId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -119,6 +132,23 @@ export default function SkillsPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab(normalizeSkillsTab(searchParams?.get('tab')));
|
||||
}, [searchParams]);
|
||||
|
||||
const changeTab = (value: string) => {
|
||||
const nextTab = normalizeSkillsTab(value);
|
||||
setActiveTab(nextTab);
|
||||
const nextParams = new URLSearchParams(searchParams?.toString());
|
||||
if (nextTab === 'published') {
|
||||
nextParams.delete('tab');
|
||||
} else {
|
||||
nextParams.set('tab', nextTab);
|
||||
}
|
||||
const query = nextParams.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
||||
};
|
||||
|
||||
const runAction = async (id: string, action: () => Promise<unknown>) => {
|
||||
setActionId(id);
|
||||
setError(null);
|
||||
@ -193,18 +223,18 @@ export default function SkillsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6 bg-white p-6 text-black [--background:0_0%_100%] [--card:0_0%_100%] [--card-foreground:0_0%_0%] [--foreground:0_0%_0%] [--muted-foreground:0_0%_0%] [--popover:0_0%_100%] [--popover-foreground:0_0%_0%] [--secondary-foreground:0_0%_0%]">
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6 overflow-x-hidden bg-white px-4 py-6 text-black [--background:0_0%_100%] [--card:0_0%_100%] [--card-foreground:0_0%_0%] [--foreground:0_0%_0%] [--muted-foreground:0_0%_0%] [--popover:0_0%_100%] [--popover-foreground:0_0%_0%] [--secondary-foreground:0_0%_0%] sm:px-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold">
|
||||
<Puzzle className="w-6 h-6" />
|
||||
{t('技能', 'Skills')}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={() => void load()} variant="outline" size="sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button onClick={() => void load()} variant="outline" size="sm" className="h-11">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('刷新', 'Refresh')}
|
||||
</Button>
|
||||
<Button onClick={() => setShowUpload(true)} size="sm">
|
||||
<Button onClick={() => setShowUpload(true)} size="sm" className="h-11">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t('上传技能', 'Upload skill')}
|
||||
</Button>
|
||||
@ -238,6 +268,7 @@ export default function SkillsPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-11"
|
||||
onClick={() => {
|
||||
setSelectedSkillName(null);
|
||||
setSkillDetail(null);
|
||||
@ -277,19 +308,20 @@ export default function SkillsPage() {
|
||||
}
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => downloadSkill(skillDetail.skill.name).catch((err) => setError(err.message))}>
|
||||
<Button variant="outline" size="sm" className="h-11" onClick={() => downloadSkill(skillDetail.skill.name).catch((err) => setError(err.message))}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('下载', 'Download')}
|
||||
</Button>
|
||||
{skillDetail.skill.source === 'workspace' && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => onRollbackSkill(skillDetail.skill.name)}>
|
||||
<Button variant="outline" size="sm" className="h-11" onClick={() => onRollbackSkill(skillDetail.skill.name)}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('回滚', 'Rollback')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-11"
|
||||
disabled={Boolean(actionId)}
|
||||
onClick={() => void runAction(`disable:${skillDetail.skill.name}`, () => disablePublishedSkill(skillDetail.skill.name, t('人工禁用', 'Manual disable')))}
|
||||
>
|
||||
@ -299,7 +331,7 @@ export default function SkillsPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
className="h-11 text-destructive hover:text-destructive"
|
||||
disabled={Boolean(actionId)}
|
||||
onClick={() => void runAction(`delete:${skillDetail.skill.name}`, () => deleteSkill(skillDetail.skill.name)).then(() => {
|
||||
setSelectedSkillName(null);
|
||||
@ -330,14 +362,14 @@ export default function SkillsPage() {
|
||||
)}
|
||||
|
||||
{!selectedSkillName && (
|
||||
<Tabs defaultValue="published" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="published">{t('已发布', 'Published')}</TabsTrigger>
|
||||
<TabsTrigger value="candidates">{t('候选', 'Candidates')}</TabsTrigger>
|
||||
<TabsTrigger value="drafts">{t('草稿评审', 'Draft review')}</TabsTrigger>
|
||||
<Tabs value={activeTab} onValueChange={changeTab} className="min-w-0 space-y-4">
|
||||
<TabsList className="h-auto min-h-11 w-full max-w-full justify-start overflow-x-auto sm:w-auto">
|
||||
<TabsTrigger value="published" className="h-10">{t('已发布', 'Published')}</TabsTrigger>
|
||||
<TabsTrigger value="candidates" className="h-10">{t('候选', 'Candidates')}</TabsTrigger>
|
||||
<TabsTrigger value="drafts" className="h-10">{t('草稿评审', 'Draft review')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="published">
|
||||
<TabsContent value="published" className="min-w-0">
|
||||
<PublishedSkillsTable
|
||||
skills={skills}
|
||||
onOpen={(name) => void openSkillDetail(name)}
|
||||
@ -350,7 +382,7 @@ export default function SkillsPage() {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="candidates">
|
||||
<TabsContent value="candidates" className="min-w-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('学习候选', 'Learning candidates')}</CardTitle>
|
||||
@ -384,7 +416,7 @@ export default function SkillsPage() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="drafts">
|
||||
<TabsContent value="drafts" className="min-w-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('草稿、评审与发布', 'Drafts, review, and publish')}</CardTitle>
|
||||
@ -473,6 +505,54 @@ function PublishedSkillsTable({
|
||||
{skills.length === 0 ? (
|
||||
<EmptyState icon={<Puzzle className="h-8 w-8" />} text={t('暂无技能', 'No skills yet')} />
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3 p-3 md:hidden">
|
||||
{skills.map((skill) => (
|
||||
<div key={`${skill.source}:${skill.name}:card`} className="min-w-0 rounded-lg border border-border bg-white p-4">
|
||||
<button
|
||||
type="button"
|
||||
className="block min-h-11 w-full text-left"
|
||||
onClick={() => onOpen(skill.name)}
|
||||
>
|
||||
<div className={`text-sm font-semibold ${containedLongTextClass}`}>{skill.name}</div>
|
||||
<div className={`mt-1 text-sm leading-5 text-muted-foreground ${containedLongTextClass}`}>
|
||||
{skill.description || '-'}
|
||||
</div>
|
||||
</button>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant={skill.source === 'builtin' ? 'secondary' : 'default'} className="text-xs">
|
||||
{skill.source === 'builtin' ? t('内置', 'Built in') : t('工作区', 'Workspace')}
|
||||
</Badge>
|
||||
<Badge variant={skill.available ? 'default' : 'outline'} className="text-xs">
|
||||
{skill.available ? t('可用', 'Available') : t('不可用', 'Unavailable')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" className="h-11" onClick={() => onDownload(skill.name)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('下载', 'Download')}
|
||||
</Button>
|
||||
{skill.source === 'workspace' && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="h-11" onClick={() => onRollback(skill.name)}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('回滚', 'Rollback')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-11" onClick={() => onDisable(skill.name)}>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
{t('禁用', 'Disable')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-11 text-destructive hover:text-destructive" onClick={() => onDelete(skill.name)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t('删除', 'Delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@ -490,7 +570,7 @@ function PublishedSkillsTable({
|
||||
className="cursor-pointer"
|
||||
onClick={() => onOpen(skill.name)}
|
||||
>
|
||||
<TableCell className="font-medium">{skill.name}</TableCell>
|
||||
<TableCell className={`font-medium ${containedLongTextClass}`}>{skill.name}</TableCell>
|
||||
<TableCell>
|
||||
<span className="block max-w-[360px] truncate text-sm text-muted-foreground">
|
||||
{skill.description}
|
||||
@ -508,7 +588,14 @@ function PublishedSkillsTable({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={(event) => { event.stopPropagation(); onDownload(skill.name); }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
aria-label={t('下载', 'Download')}
|
||||
title={t('下载', 'Download')}
|
||||
onClick={(event) => { event.stopPropagation(); onDownload(skill.name); }}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{skill.source === 'workspace' && (
|
||||
@ -516,7 +603,9 @@ function PublishedSkillsTable({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
className="h-11 w-11"
|
||||
aria-label={t('回滚', 'Rollback')}
|
||||
title={t('回滚', 'Rollback')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRollback(skill.name);
|
||||
@ -527,7 +616,9 @@ function PublishedSkillsTable({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
className="h-11 w-11"
|
||||
aria-label={t('禁用', 'Disable')}
|
||||
title={t('禁用', 'Disable')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDisable(skill.name);
|
||||
@ -538,7 +629,9 @@ function PublishedSkillsTable({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
className="h-11 w-11 text-destructive hover:text-destructive"
|
||||
aria-label={t('删除', 'Delete')}
|
||||
title={t('删除', 'Delete')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete(skill.name);
|
||||
@ -554,6 +647,8 @@ function PublishedSkillsTable({
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -590,7 +685,7 @@ function CandidateCard({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-white p-4">
|
||||
<div className="min-w-0 max-w-full rounded-lg border border-border bg-white p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@ -607,7 +702,7 @@ function CandidateCard({
|
||||
|
||||
<div>
|
||||
<h3 className="break-words text-base font-semibold tracking-normal">{title}</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
<p className={`mt-1 text-sm leading-6 text-muted-foreground ${containedLongTextClass}`}>
|
||||
{candidate.reason || t('没有提供候选理由。', 'No candidate reason was provided.')}
|
||||
</p>
|
||||
</div>
|
||||
@ -656,7 +751,7 @@ function CandidateCard({
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{candidate.candidate_id}</span>
|
||||
<span className={`font-mono ${containedLongTextClass}`}>{candidate.candidate_id}</span>
|
||||
{String(evidence.task_id || '') && <span>{t('任务', 'Task')}: {String(evidence.task_id)}</span>}
|
||||
{String(evidence.skill_version || '') && <span>{t('基线版本', 'Base version')}: {String(evidence.skill_version)}</span>}
|
||||
{candidate.created_at && <span>{t('创建于', 'Created')}: {formatDateTime(candidate.created_at)}</span>}
|
||||
@ -666,11 +761,12 @@ function CandidateCard({
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={Boolean(actionId)} onClick={onIgnore}>
|
||||
<Button size="sm" variant="outline" className="h-11" disabled={Boolean(actionId)} onClick={onIgnore}>
|
||||
{t('忽略', 'Ignore')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-11"
|
||||
disabled={Boolean(actionId)}
|
||||
onClick={() => void onSynthesize()}
|
||||
>
|
||||
@ -685,6 +781,7 @@ function CandidateCard({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-11"
|
||||
disabled={Boolean(actionId)}
|
||||
onClick={() => void onRegenerate()}
|
||||
>
|
||||
@ -753,7 +850,7 @@ function DraftCard({
|
||||
void onPublish(isHighRisk);
|
||||
};
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-white p-4">
|
||||
<div className="min-w-0 max-w-full rounded-lg border border-border bg-white p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@ -776,9 +873,9 @@ function DraftCard({
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">{t('技能名', 'Skill name')}</div>
|
||||
<h3 className="break-words text-lg font-semibold tracking-normal">{draft.skill_name}</h3>
|
||||
<h3 className={`text-lg font-semibold tracking-normal ${containedLongTextClass}`}>{draft.skill_name}</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||
<p className={`mt-1 text-sm leading-6 text-muted-foreground ${containedLongTextClass}`}>
|
||||
{draft.reason || description || t('没有提供草稿说明。', 'No draft notes were provided.')}
|
||||
</p>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
||||
@ -805,37 +902,37 @@ function DraftCard({
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{draft.skill_name}/{draft.draft_id}</span>
|
||||
<span className={`font-mono ${containedLongTextClass}`}>{draft.skill_name}/{draft.draft_id}</span>
|
||||
<span>{t('创建者', 'Author')}: {draft.created_by}</span>
|
||||
<span>{t('创建于', 'Created')}: {formatDateTime(draft.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" disabled={busy || submitBlocked} onClick={() => void onSubmit()}>
|
||||
<Button variant="outline" size="sm" className="h-11" disabled={busy || submitBlocked} onClick={() => void onSubmit()}>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
{t('送审', 'Submit')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={busy || approveBlocked} onClick={() => void onApprove()}>
|
||||
<Button variant="outline" size="sm" className="h-11" disabled={busy || approveBlocked} onClick={() => void onApprove()}>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
{t('批准', 'Approve')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={busy || rejectBlocked} onClick={() => void onReject()}>
|
||||
<Button variant="outline" size="sm" className="h-11" disabled={busy || rejectBlocked} onClick={() => void onReject()}>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
{t('拒绝', 'Reject')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={busy || TERMINAL_DRAFT_STATUSES.has(draft.status)} onClick={() => void onRecheckSafety()}>
|
||||
<Button variant="outline" size="sm" className="h-11" disabled={busy || TERMINAL_DRAFT_STATUSES.has(draft.status)} onClick={() => void onRecheckSafety()}>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
{t('复检', 'Recheck')}
|
||||
</Button>
|
||||
<Button size="sm" disabled={busy || publishBlocked} onClick={handlePublish}>
|
||||
<Button size="sm" className="h-11" disabled={busy || publishBlocked} onClick={handlePublish}>
|
||||
<Rocket className="mr-2 h-4 w-4" />
|
||||
{t('发布', 'Publish')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div className="rounded-md border border-border bg-muted/20 p-4">
|
||||
<div className="mt-4 grid min-w-0 gap-3 lg:grid-cols-[minmax(0,1fr)_340px]">
|
||||
<div className="min-w-0 max-w-full rounded-md border border-border bg-muted/20 p-3 sm:p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
@ -858,7 +955,7 @@ function DraftCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<GateSummary
|
||||
title={t('发布门禁', 'Publish gates')}
|
||||
summary={canPublishLabel}
|
||||
@ -883,7 +980,7 @@ function DraftCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<div className="mt-3 grid min-w-0 gap-3 md:grid-cols-2">
|
||||
<SafetyReportPanel report={safety} />
|
||||
<EvalReportPanel report={evalReport} />
|
||||
</div>
|
||||
@ -905,7 +1002,7 @@ function SafetyReportPanel({ report }: { report?: SkillDraftSafetyReport | null
|
||||
}
|
||||
const problems = [...(report.blocked_reasons || []), ...(report.issues || [])];
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/20 p-4">
|
||||
<div className="min-w-0 rounded-md border border-border bg-muted/20 p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
{report.passed ? <ShieldCheck className="h-4 w-4 text-muted-foreground" /> : <ShieldAlert className="h-4 w-4 text-destructive" />}
|
||||
@ -922,7 +1019,7 @@ function SafetyReportPanel({ report }: { report?: SkillDraftSafetyReport | null
|
||||
{problems.map((item, index) => (
|
||||
<li key={`${item}:${index}`} className="flex gap-2">
|
||||
<AlertCircle className="mt-1 h-3.5 w-3.5 shrink-0 text-destructive" />
|
||||
<span>{item}</span>
|
||||
<span className={containedLongTextClass}>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@ -933,7 +1030,7 @@ function SafetyReportPanel({ report }: { report?: SkillDraftSafetyReport | null
|
||||
</p>
|
||||
)}
|
||||
{report.suggested_fix && (
|
||||
<p className="mt-3 rounded-md border border-border bg-white p-3 text-sm">
|
||||
<p className={`mt-3 rounded-md border border-border bg-white p-3 text-sm ${containedLongTextClass}`}>
|
||||
<span className="font-medium">{t('建议处理', 'Suggested fix')}:</span> {report.suggested_fix}
|
||||
</p>
|
||||
)}
|
||||
@ -957,7 +1054,7 @@ function EvalReportPanel({ report }: { report?: SkillDraftEvalReport | null }) {
|
||||
}
|
||||
if (report.status === 'skipped_provider_unavailable') {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/20 p-4">
|
||||
<div className="min-w-0 rounded-md border border-border bg-muted/20 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
{t('评估报告', 'Eval report')}
|
||||
@ -970,7 +1067,7 @@ function EvalReportPanel({ report }: { report?: SkillDraftEvalReport | null }) {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/20 p-4">
|
||||
<div className="min-w-0 rounded-md border border-border bg-muted/20 p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
@ -1002,7 +1099,19 @@ function EvalReportPanel({ report }: { report?: SkillDraftEvalReport | null }) {
|
||||
<div className="border-b border-border px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
{t('回放案例', 'Replay cases')}
|
||||
</div>
|
||||
<div className="max-h-48 overflow-auto">
|
||||
<div className="space-y-2 p-3 md:hidden">
|
||||
{report.cases.map((item, index) => (
|
||||
<div key={`${String(item.run_id || index)}:${index}:card`} className="rounded-md border border-border bg-muted/20 p-3 text-xs">
|
||||
<div className={`font-mono ${containedLongTextClass}`}>{String(item.run_id || '-')}</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
<MetricTile label={t('基线', 'Baseline')} value={formatScore(toNumber(item.baseline_score))} />
|
||||
<MetricTile label={t('候选', 'Candidate')} value={formatScore(toNumber(item.candidate_score))} />
|
||||
<MetricTile label={t('变化', 'Delta')} value={formatSignedScore(toNumber(item.delta))} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden max-h-48 overflow-auto md:block">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-muted/40 text-muted-foreground">
|
||||
<tr>
|
||||
@ -1042,7 +1151,7 @@ function GateSummary({
|
||||
items: Array<{ label: string; ok: boolean }>;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/20 p-4">
|
||||
<div className="min-w-0 rounded-md border border-border bg-muted/20 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<ListChecks className="h-4 w-4 text-muted-foreground" />
|
||||
{title}
|
||||
@ -1070,7 +1179,7 @@ function ReadablePanel({
|
||||
empty: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/20 p-4">
|
||||
<div className="min-w-0 rounded-md border border-border bg-muted/20 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
{icon}
|
||||
{title}
|
||||
@ -1090,7 +1199,7 @@ function ReadableFact({
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-white p-3">
|
||||
<div className="min-w-0 rounded-md border border-border bg-white p-3">
|
||||
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
@ -1111,7 +1220,7 @@ function MetricTile({
|
||||
}) {
|
||||
const toneClass = tone === 'bad' ? 'text-destructive' : 'text-foreground';
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-white p-3">
|
||||
<div className="min-w-0 rounded-md border border-border bg-white p-3">
|
||||
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
||||
<div className={`mt-1 text-lg font-semibold ${toneClass}`}>{value}</div>
|
||||
</div>
|
||||
@ -1121,7 +1230,7 @@ function MetricTile({
|
||||
function RawDetails({ title, payload }: { title: string; payload: unknown }) {
|
||||
return (
|
||||
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-white">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<summary className="flex h-11 cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
{title}
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</summary>
|
||||
@ -1134,7 +1243,7 @@ function RawDetails({ title, payload }: { title: string; payload: unknown }) {
|
||||
|
||||
function MarkdownPreview({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="prose prose-sm max-w-none text-black prose-a:text-black prose-code:rounded prose-code:bg-white prose-code:px-1 prose-code:py-0.5 prose-code:text-black prose-headings:text-black prose-headings:tracking-normal prose-li:text-black prose-p:text-black prose-pre:border prose-pre:border-border prose-pre:bg-white prose-pre:text-black prose-strong:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
<div className={`prose prose-sm max-w-none text-black prose-a:text-black prose-code:rounded prose-code:bg-white prose-code:px-1 prose-code:py-0.5 prose-code:text-black prose-headings:text-black prose-headings:tracking-normal prose-li:text-black prose-p:text-black prose-pre:border prose-pre:border-border prose-pre:bg-white prose-pre:text-black prose-strong:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_*]:min-w-0 ${containedLongTextClass}`}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -21,6 +21,7 @@ import {
|
||||
getChannelConfig,
|
||||
getChannelConnectorSession,
|
||||
getStatus,
|
||||
listChannelConnections,
|
||||
listChannelConnectors,
|
||||
listChannelEvents,
|
||||
restartRuntime,
|
||||
@ -54,6 +55,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import type {
|
||||
ChannelConfigDetail,
|
||||
ChannelConnectorDescriptor,
|
||||
ChannelConnectionView,
|
||||
ChannelEventRecord,
|
||||
ChannelStatus,
|
||||
ConnectorSessionResponse,
|
||||
@ -62,6 +64,7 @@ import type {
|
||||
} from '@/types';
|
||||
import { AppLocale, pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { connectorChannelForKind, visibleConnectorCards } from '@/lib/channel-connector-state';
|
||||
|
||||
type ProviderFormState = {
|
||||
enabled: boolean;
|
||||
@ -137,6 +140,8 @@ const EMPTY_CHANNEL_FORM: ChannelFormState = {
|
||||
|
||||
const CONFIGURABLE_CHANNEL_KINDS = new Set(['telegram', 'feishu', 'qqbot', 'weixin']);
|
||||
const SESSION_CONNECTOR_KINDS = new Set(['weixin', 'feishu']);
|
||||
const VISIBLE_PROVIDER_IDS = new Set(['openai', 'deepseek', 'dashscope', 'vllm']);
|
||||
const LOCAL_CONNECTOR_KINDS = new Set(['terminal']);
|
||||
|
||||
type ConnectorWizardForm = {
|
||||
kind: string;
|
||||
@ -146,6 +151,12 @@ type ConnectorWizardForm = {
|
||||
appId: string;
|
||||
appSecret: string;
|
||||
verificationToken: string;
|
||||
requireMentionInGroups: boolean;
|
||||
respondToMentionAll: boolean;
|
||||
dmMode: 'open' | 'allowlist' | 'pair' | 'disabled';
|
||||
allowFrom: string;
|
||||
groupAllowFrom: string;
|
||||
maxMessageChars: string;
|
||||
};
|
||||
|
||||
const EMPTY_CONNECTOR_WIZARD: ConnectorWizardForm = {
|
||||
@ -156,6 +167,12 @@ const EMPTY_CONNECTOR_WIZARD: ConnectorWizardForm = {
|
||||
appId: '',
|
||||
appSecret: '',
|
||||
verificationToken: '',
|
||||
requireMentionInGroups: true,
|
||||
respondToMentionAll: false,
|
||||
dmMode: 'open',
|
||||
allowFrom: '',
|
||||
groupAllowFrom: '',
|
||||
maxMessageChars: '20000',
|
||||
};
|
||||
|
||||
export default function StatusPage() {
|
||||
@ -193,6 +210,7 @@ export default function StatusPage() {
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [restartError, setRestartError] = useState<string | null>(null);
|
||||
const [connectors, setConnectors] = useState<ChannelConnectorDescriptor[]>([]);
|
||||
const [channelConnections, setChannelConnections] = useState<ChannelConnectionView[]>([]);
|
||||
const [loadingConnectors, setLoadingConnectors] = useState(false);
|
||||
const [connectorDialogOpen, setConnectorDialogOpen] = useState(false);
|
||||
const [connectorForm, setConnectorForm] = useState<ConnectorWizardForm>(() => ({ ...EMPTY_CONNECTOR_WIZARD }));
|
||||
@ -234,8 +252,17 @@ export default function StatusPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadChannelConnections = async () => {
|
||||
try {
|
||||
setChannelConnections(await listChannelConnections());
|
||||
} catch {
|
||||
setChannelConnections([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadConnectors();
|
||||
void loadChannelConnections();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@ -329,8 +356,9 @@ export default function StatusPage() {
|
||||
setChannelError(null);
|
||||
setChannelRestartRequired(false);
|
||||
setChannelEvents([]);
|
||||
setLoadingChannelConfig(true);
|
||||
setLoadingChannelConfig(channel.kind !== 'terminal');
|
||||
setLoadingChannelEvents(true);
|
||||
if (channel.kind !== 'terminal') {
|
||||
try {
|
||||
const config = await getChannelConfig(channel.channel_id);
|
||||
setChannelConfig(config);
|
||||
@ -340,6 +368,9 @@ export default function StatusPage() {
|
||||
} finally {
|
||||
setLoadingChannelConfig(false);
|
||||
}
|
||||
} else {
|
||||
setLoadingChannelConfig(false);
|
||||
}
|
||||
try {
|
||||
setChannelEvents(await listChannelEvents(channel.channel_id, 20));
|
||||
} catch {
|
||||
@ -396,6 +427,11 @@ export default function StatusPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const openLocalConnectorDetails = (kind: string, channel?: ChannelStatus) => {
|
||||
if (kind !== 'terminal') return;
|
||||
void openChannelDetails(channel || terminalFallbackChannel(locale));
|
||||
};
|
||||
|
||||
const handleStartConnectorSession = async () => {
|
||||
if (!connectorForm.kind || !SESSION_CONNECTOR_KINDS.has(connectorForm.kind)) return;
|
||||
setStartingConnector(true);
|
||||
@ -405,6 +441,14 @@ export default function StatusPage() {
|
||||
if (connectorForm.kind === 'feishu') {
|
||||
options.domain = connectorForm.domain || 'feishu';
|
||||
options.mode = connectorForm.mode;
|
||||
options.requireMentionInGroups = connectorForm.requireMentionInGroups;
|
||||
options.respondToMentionAll = connectorForm.respondToMentionAll;
|
||||
options.dmMode = connectorForm.dmMode;
|
||||
const allowFrom = parseList(connectorForm.allowFrom);
|
||||
const groupAllowFrom = parseList(connectorForm.groupAllowFrom);
|
||||
if (allowFrom.length) options.allowFrom = allowFrom;
|
||||
if (groupAllowFrom.length) options.groupAllowFrom = groupAllowFrom;
|
||||
if (connectorForm.maxMessageChars.trim()) options.maxMessageChars = Number(connectorForm.maxMessageChars.trim());
|
||||
if (connectorForm.appId.trim()) options.appId = connectorForm.appId.trim();
|
||||
if (connectorForm.appSecret.trim()) options.appSecret = connectorForm.appSecret.trim();
|
||||
if (connectorForm.verificationToken.trim()) options.verificationToken = connectorForm.verificationToken.trim();
|
||||
@ -415,6 +459,10 @@ export default function StatusPage() {
|
||||
options,
|
||||
});
|
||||
setConnectorSession(response);
|
||||
if (response.session.status === 'connected') {
|
||||
await loadStatus();
|
||||
await loadChannelConnections();
|
||||
}
|
||||
if (!connectorSessionDone(response.session.status)) {
|
||||
window.setTimeout(() => {
|
||||
void pollConnectorSession(response.session.sessionId);
|
||||
@ -434,6 +482,7 @@ export default function StatusPage() {
|
||||
setConnectorSession(response);
|
||||
if (response.session.status === 'connected') {
|
||||
await loadStatus();
|
||||
await loadChannelConnections();
|
||||
}
|
||||
} catch (err: any) {
|
||||
setConnectorError(err.message || pickAppText(locale, '刷新连接状态失败', 'Failed to refresh connector status'));
|
||||
@ -452,14 +501,14 @@ export default function StatusPage() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div className="mx-auto max-w-4xl p-4 sm:p-6">
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3 text-destructive">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<div>
|
||||
<div className="flex items-start gap-3 text-destructive">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">{pickAppText(locale, '无法连接到 Boardware Agent Sandbox 后端', 'Unable to connect to the Boardware Agent Sandbox backend')}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{error}</p>
|
||||
<p className="mt-1 break-words text-sm text-muted-foreground">{error}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{pickAppText(locale, '请确认后端服务已启动,并且当前页面可以访问它。', 'Please confirm the backend service is running and reachable from this page.')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -475,8 +524,11 @@ export default function StatusPage() {
|
||||
|
||||
if (!status) return null;
|
||||
|
||||
const visibleProviders = status.providers.filter(visibleProvider);
|
||||
const connectorCards = visibleConnectorCards(connectors);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl p-6 space-y-6">
|
||||
<div className="mx-auto max-w-6xl space-y-6 p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{pickAppText(locale, '配置', 'Settings')}</h1>
|
||||
@ -575,7 +627,7 @@ export default function StatusPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-destructive">{agentError || ''}</div>
|
||||
<div className="min-w-0 break-words text-sm text-destructive">{agentError || ''}</div>
|
||||
<Button onClick={handleSaveAgentConfig} disabled={savingAgent} className="sm:self-end">
|
||||
{savingAgent ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{pickAppText(locale, '保存智能体配置', 'Save agent config')}
|
||||
@ -594,13 +646,13 @@ export default function StatusPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{status.providers.map((p) => (
|
||||
{visibleProviders.map((p) => (
|
||||
<button
|
||||
key={p.id || p.name}
|
||||
type="button"
|
||||
onClick={() => openProviderDialog(p)}
|
||||
className={[
|
||||
'group flex min-h-[76px] w-full items-start justify-between rounded-lg border p-3 text-left transition',
|
||||
'group flex min-h-[76px] w-full items-start justify-between gap-3 rounded-lg border p-3 text-left transition',
|
||||
p.active
|
||||
? 'border-primary bg-primary/5 shadow-sm'
|
||||
: 'border-border bg-background hover:border-primary/50 hover:bg-muted/40',
|
||||
@ -613,11 +665,11 @@ export default function StatusPage() {
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 shrink-0 text-muted-foreground/40" />
|
||||
)}
|
||||
<span className={p.has_key ? 'truncate' : 'truncate text-muted-foreground'}>
|
||||
<span className={p.has_key ? 'break-all' : 'break-all text-muted-foreground'}>
|
||||
{providerLabel(p)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
<span className="block break-words text-xs text-muted-foreground">
|
||||
{p.active
|
||||
? pickAppText(locale, '当前默认', 'Current default')
|
||||
: p.enabled
|
||||
@ -625,7 +677,7 @@ export default function StatusPage() {
|
||||
: pickAppText(locale, '点击配置', 'Click to configure')}
|
||||
</span>
|
||||
{(p.detail || p.api_key_masked) && (
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
<span className="block break-all text-xs text-muted-foreground">
|
||||
{p.api_key_masked || p.detail}
|
||||
</span>
|
||||
)}
|
||||
@ -639,7 +691,7 @@ export default function StatusPage() {
|
||||
|
||||
<Dialog open={Boolean(selectedProvider)} onOpenChange={(open) => !open && setSelectedProvider(null)}>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogHeader className="pr-10">
|
||||
<DialogTitle>
|
||||
{pickAppText(locale, '配置提供商', 'Configure provider')}
|
||||
{selectedProvider ? ` · ${providerLabel(selectedProvider)}` : ''}
|
||||
@ -649,8 +701,8 @@ export default function StatusPage() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5 py-2">
|
||||
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<Label className="text-sm">{pickAppText(locale, '启用提供商', 'Enable provider')}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '关闭会从配置中移除这个提供商', 'Turning this off removes this provider from config')}
|
||||
@ -709,7 +761,7 @@ export default function StatusPage() {
|
||||
</div>
|
||||
|
||||
{providerError ? (
|
||||
<p className="text-sm text-destructive">{providerError}</p>
|
||||
<p className="break-words text-sm text-destructive">{providerError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
@ -725,19 +777,22 @@ export default function StatusPage() {
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(selectedChannel)} onOpenChange={(open) => !open && setSelectedChannel(null)}>
|
||||
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-[820px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedChannel?.display_name || selectedChannel?.channel_id}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<DialogContent className="sm:max-w-[820px]">
|
||||
<DialogHeader className="pr-10">
|
||||
<DialogTitle className="break-words leading-tight">{selectedChannel?.display_name || selectedChannel?.channel_id}</DialogTitle>
|
||||
<DialogDescription className="break-all">
|
||||
{selectedChannel ? `${selectedChannel.kind}/${selectedChannel.mode} · ${selectedChannel.channel_id}` : ''}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedChannel ? (
|
||||
<div className="space-y-5">
|
||||
{selectedChannel.kind === 'terminal' ? (
|
||||
<TerminalConnectionGuide channel={selectedChannel} locale={locale} />
|
||||
) : null}
|
||||
{CONFIGURABLE_CHANNEL_KINDS.has(channelForm.kind) ? (
|
||||
<div className="space-y-5 rounded-lg border p-4">
|
||||
<div className="min-w-0 space-y-5 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{pickAppText(locale, '连接配置', 'Connection settings')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '凭据留空会保留已保存的值。保存后重启实例才会重新连接通道。', 'Leave credentials blank to keep saved values. Restart the instance after saving to reconnect channels.')}
|
||||
@ -751,15 +806,17 @@ export default function StatusPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label={pickAppText(locale, '显示名', 'Display name')}>
|
||||
<Field id="channel-display-name" label={pickAppText(locale, '显示名', 'Display name')}>
|
||||
<Input
|
||||
id="channel-display-name"
|
||||
value={channelForm.displayName}
|
||||
onChange={(event) => setChannelForm((prev) => ({ ...prev, displayName: event.target.value }))}
|
||||
placeholder={selectedChannel.display_name || selectedChannel.channel_id}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Account ID">
|
||||
<Field id="channel-account-id" label="Account ID">
|
||||
<Input
|
||||
id="channel-account-id"
|
||||
value={channelForm.accountId}
|
||||
onChange={(event) => setChannelForm((prev) => ({ ...prev, accountId: event.target.value }))}
|
||||
placeholder="bot-main"
|
||||
@ -810,11 +867,11 @@ export default function StatusPage() {
|
||||
/>
|
||||
<ChannelPolicyFields form={channelForm} locale={locale} setForm={setChannelForm} />
|
||||
|
||||
{channelError ? <p className="text-sm text-destructive">{channelError}</p> : null}
|
||||
{channelError ? <p className="break-words text-sm text-destructive">{channelError}</p> : null}
|
||||
{channelRestartRequired ? (
|
||||
<div className="flex flex-col gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>{pickAppText(locale, '配置已保存。重启实例后通道会按新配置启动。', 'Configuration saved. Restart the instance to start channels with the new settings.')}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setRestartOpen(true)}>
|
||||
<span className="min-w-0 break-words">{pickAppText(locale, '配置已保存。重启实例后通道会按新配置启动。', 'Configuration saved. Restart the instance to start channels with the new settings.')}</span>
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setRestartOpen(true)}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '重启实例', 'Restart instance')}
|
||||
</Button>
|
||||
@ -844,11 +901,11 @@ export default function StatusPage() {
|
||||
<div className="max-h-[320px] overflow-auto rounded-md border">
|
||||
{channelEvents.map((event) => (
|
||||
<div key={event.event_id} className="border-b px-3 py-2 text-xs last:border-b-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{event.kind}</span>
|
||||
<span className="text-muted-foreground">{event.created_at}</span>
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
|
||||
<span className="break-all font-medium">{event.kind}</span>
|
||||
<span className="break-all text-muted-foreground">{event.created_at}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
<div className="mt-1 break-words text-muted-foreground">
|
||||
{event.status}{event.error ? ` · ${event.error}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
@ -868,7 +925,7 @@ export default function StatusPage() {
|
||||
|
||||
<Dialog open={restartOpen} onOpenChange={setRestartOpen}>
|
||||
<DialogContent className="sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogHeader className="pr-10">
|
||||
<DialogTitle>{pickAppText(locale, '重启实例?', 'Restart instance?')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pickAppText(
|
||||
@ -878,7 +935,7 @@ export default function StatusPage() {
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{restartError ? <p className="text-sm text-destructive">{restartError}</p> : null}
|
||||
{restartError ? <p className="break-words text-sm text-destructive">{restartError}</p> : null}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRestartOpen(false)} disabled={restarting}>
|
||||
{pickAppText(locale, '取消', 'Cancel')}
|
||||
@ -898,8 +955,8 @@ export default function StatusPage() {
|
||||
setConnectorError(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader className="pr-10">
|
||||
<DialogTitle>
|
||||
{connectorForm.kind ? connectorDisplayName({ kind: connectorForm.kind }) : pickAppText(locale, '连接通道', 'Connect channel')}
|
||||
</DialogTitle>
|
||||
@ -913,8 +970,9 @@ export default function StatusPage() {
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5">
|
||||
<Field label={pickAppText(locale, '显示名', 'Display name')}>
|
||||
<Field id="connector-display-name" label={pickAppText(locale, '显示名', 'Display name')}>
|
||||
<Input
|
||||
id="connector-display-name"
|
||||
value={connectorForm.displayName}
|
||||
onChange={(event) => setConnectorForm((prev) => ({ ...prev, displayName: event.target.value }))}
|
||||
disabled={Boolean(connectorSession) || startingConnector}
|
||||
@ -961,6 +1019,75 @@ export default function StatusPage() {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-4 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{pickAppText(locale, '群聊必须 @ Beaver', 'Require @ in groups')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '默认开启,避免群聊所有消息触发智能体。', 'Enabled by default to avoid processing every group message.')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={connectorForm.requireMentionInGroups}
|
||||
onCheckedChange={(checked) => setConnectorForm((prev) => ({ ...prev, requireMentionInGroups: checked }))}
|
||||
disabled={Boolean(connectorSession) || startingConnector}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{pickAppText(locale, '响应 @所有人', 'Respond to @all')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '默认关闭,避免群公告式消息触发。', 'Disabled by default to avoid broadcast-style triggers.')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={connectorForm.respondToMentionAll}
|
||||
onCheckedChange={(checked) => setConnectorForm((prev) => ({ ...prev, respondToMentionAll: checked }))}
|
||||
disabled={Boolean(connectorSession) || startingConnector}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label={pickAppText(locale, '私聊策略', 'DM policy')}>
|
||||
<Select
|
||||
value={connectorForm.dmMode}
|
||||
onValueChange={(value) => setConnectorForm((prev) => ({ ...prev, dmMode: value as ConnectorWizardForm['dmMode'] }))}
|
||||
disabled={Boolean(connectorSession) || startingConnector}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">{pickAppText(locale, '开放', 'Open')}</SelectItem>
|
||||
<SelectItem value="allowlist">{pickAppText(locale, '白名单', 'Allowlist')}</SelectItem>
|
||||
<SelectItem value="pair">{pickAppText(locale, '已配对', 'Paired')}</SelectItem>
|
||||
<SelectItem value="disabled">{pickAppText(locale, '关闭', 'Disabled')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label={pickAppText(locale, '最大入站长度', 'Max inbound chars')}>
|
||||
<Input
|
||||
value={connectorForm.maxMessageChars}
|
||||
onChange={(event) => setConnectorForm((prev) => ({ ...prev, maxMessageChars: event.target.value }))}
|
||||
disabled={Boolean(connectorSession) || startingConnector}
|
||||
placeholder="20000"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={pickAppText(locale, '允许私聊用户 Open ID', 'Allowed DM user Open IDs')}>
|
||||
<Textarea
|
||||
value={connectorForm.allowFrom}
|
||||
onChange={(event) => setConnectorForm((prev) => ({ ...prev, allowFrom: event.target.value }))}
|
||||
disabled={Boolean(connectorSession) || startingConnector}
|
||||
placeholder="ou_xxx, ou_yyy"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={pickAppText(locale, '允许群 Chat ID', 'Allowed group Chat IDs')}>
|
||||
<Textarea
|
||||
value={connectorForm.groupAllowFrom}
|
||||
onChange={(event) => setConnectorForm((prev) => ({ ...prev, groupAllowFrom: event.target.value }))}
|
||||
disabled={Boolean(connectorSession) || startingConnector}
|
||||
placeholder="oc_xxx, oc_yyy"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{connectorForm.mode === 'link' ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="App ID">
|
||||
@ -997,8 +1124,8 @@ export default function StatusPage() {
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{connectorSession.session.sessionId}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="break-all text-sm font-medium">{connectorSession.session.sessionId}</p>
|
||||
<p className="break-all text-xs text-muted-foreground">
|
||||
{connectorSession.connection?.channel_id || connectorSession.connection?.connection_id || '-'}
|
||||
</p>
|
||||
</div>
|
||||
@ -1020,7 +1147,7 @@ export default function StatusPage() {
|
||||
<div className="space-y-2 text-sm">
|
||||
{connectorSession.session.instructions.map((item, index) => (
|
||||
<div key={`${index}-${item}`} className="rounded-md border bg-muted/30 px-3 py-2">
|
||||
{item}
|
||||
<span className="break-words">{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -1032,12 +1159,12 @@ export default function StatusPage() {
|
||||
</div>
|
||||
) : null}
|
||||
{connectorSession.session.error ? (
|
||||
<p className="text-sm text-destructive">{connectorSession.session.error}</p>
|
||||
<p className="break-words text-sm text-destructive">{connectorSession.session.error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{connectorError ? <p className="text-sm text-destructive">{connectorError}</p> : null}
|
||||
{connectorError ? <p className="break-words text-sm text-destructive">{connectorError}</p> : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@ -1073,31 +1200,60 @@ export default function StatusPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{(connectors.length ? connectors : [{ kind: 'telegram' }, { kind: 'weixin' }, { kind: 'feishu' }]).map((connector) => {
|
||||
const supportsSession = SESSION_CONNECTOR_KINDS.has(connector.kind);
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{connectorCards.map((connector) => {
|
||||
const channel = connectorChannelForKind(connector.kind, status.channels);
|
||||
const connection = connectorConnectionForKind(connector.kind, channelConnections);
|
||||
const isRunning = channel?.state === 'running' || connection?.status === 'connected';
|
||||
const isLocalConnector = LOCAL_CONNECTOR_KINDS.has(connector.kind);
|
||||
const canStart = SESSION_CONNECTOR_KINDS.has(connector.kind) && !channel && !isRunning;
|
||||
return (
|
||||
<button
|
||||
key={connector.kind}
|
||||
type="button"
|
||||
onClick={() => supportsSession && openConnectorDialog(connector)}
|
||||
disabled={!supportsSession}
|
||||
onClick={() => {
|
||||
if (isLocalConnector) {
|
||||
openLocalConnectorDetails(connector.kind, channel);
|
||||
} else if (channel) {
|
||||
void openChannelDetails(channel);
|
||||
} else if (canStart) {
|
||||
openConnectorDialog(connector);
|
||||
}
|
||||
}}
|
||||
disabled={!channel && !canStart && !isLocalConnector}
|
||||
className={[
|
||||
'flex min-h-[86px] w-full items-start justify-between rounded-lg border px-3 py-3 text-left text-sm transition',
|
||||
supportsSession ? 'hover:border-primary/50 hover:bg-muted/40' : 'opacity-70',
|
||||
'group flex min-h-[76px] w-full items-start justify-between gap-3 rounded-lg border p-3 text-left transition',
|
||||
isRunning
|
||||
? 'border-primary bg-primary/5 shadow-sm'
|
||||
: canStart
|
||||
? 'border-border bg-background hover:border-primary/50 hover:bg-muted/40'
|
||||
: 'border-border bg-background opacity-70',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="min-w-0 space-y-1">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
{supportsSession ? <QrCode className="h-4 w-4" /> : <PlugZap className="h-4 w-4" />}
|
||||
<span className="truncate">{connectorDisplayName(connector)}</span>
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{connectorAuthLabel(connector, locale)}
|
||||
<span className="flex items-center gap-2 text-sm font-medium">
|
||||
{isRunning ? (
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-green-500" />
|
||||
) : canStart || isLocalConnector ? (
|
||||
<QrCode className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<PlugZap className="h-4 w-4 shrink-0 text-muted-foreground/50" />
|
||||
)}
|
||||
<span className={isRunning ? 'break-all' : 'break-all text-muted-foreground'}>
|
||||
{connectorDisplayName(connector)}
|
||||
</span>
|
||||
</span>
|
||||
<Badge variant={supportsSession ? 'outline' : 'secondary'}>
|
||||
{supportsSession ? pickAppText(locale, '连接', 'Connect') : pickAppText(locale, '令牌', 'Token')}
|
||||
<span className="block break-words text-xs text-muted-foreground">
|
||||
{connectorCardSubtitle(connector, channel, connection, locale)}
|
||||
</span>
|
||||
{channel || connection || isLocalConnector ? (
|
||||
<span className="block break-all text-xs text-muted-foreground">
|
||||
{connectorCardChannelLabel(channel, connection, locale)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<Badge variant={connectorCardBadgeVariant(channel, connection)} className="shrink-0">
|
||||
{connectorCardBadgeLabel(connector, channel, connection, locale)}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
@ -1109,33 +1265,6 @@ export default function StatusPage() {
|
||||
{pickAppText(locale, '正在加载连接器', 'Loading connectors')}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
{status.channels.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '尚未配置通道', 'No channels configured')}
|
||||
</p>
|
||||
) : (
|
||||
status.channels.map((ch) => (
|
||||
<button
|
||||
key={ch.channel_id}
|
||||
type="button"
|
||||
onClick={() => openChannelDetails(ch)}
|
||||
className="flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left text-sm hover:bg-muted/40"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium">{ch.display_name || ch.channel_id}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{ch.channel_id} · {ch.kind}/{ch.mode} · {ch.account_id}
|
||||
{typeof ch.connected_peers === 'number' ? ` · ${ch.connected_peers} peer${ch.connected_peers === 1 ? '' : 's'}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<Badge variant={channelStateBadgeVariant(ch.state)}>
|
||||
{ch.state}
|
||||
</Badge>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -1154,10 +1283,10 @@ function InfoRow({
|
||||
ok?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted px-2 py-0.5 rounded text-xs max-w-[400px] truncate">
|
||||
<div className="grid min-w-0 gap-1 text-sm sm:grid-cols-[minmax(0,1fr)_minmax(0,auto)] sm:items-start">
|
||||
<span className="min-w-0 break-words text-muted-foreground">{label}</span>
|
||||
<div className="flex min-w-0 items-center gap-2 sm:justify-end">
|
||||
<code className="min-w-0 max-w-full whitespace-normal break-all rounded bg-muted px-2 py-0.5 text-xs sm:max-w-[400px]">
|
||||
{value}
|
||||
</code>
|
||||
{ok !== undefined &&
|
||||
@ -1171,14 +1300,180 @@ function InfoRow({
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalConnectionGuide({ channel, locale }: { channel: ChannelStatus; locale: AppLocale }) {
|
||||
const connected = channel.state === 'running';
|
||||
const instructions = terminalConnectionGuide(locale);
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{pickAppText(locale, '小终端连接方式', 'Terminal connection method')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pickAppText(
|
||||
locale,
|
||||
'小终端通过本地 WebSocket 通道接入当前实例;这里展示的是连接状态和接入说明。',
|
||||
'The terminal connects to this instance through the local WebSocket channel; this panel shows status and connection guidance.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={connected ? 'default' : 'secondary'}>{channel.state}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
<div className="mx-auto grid h-44 w-44 grid-cols-7 grid-rows-7 gap-1 rounded bg-background p-3">
|
||||
{TERMINAL_FAKE_QR_CELLS.map((filled, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={filled ? 'rounded-[2px] bg-foreground' : 'rounded-[2px] bg-transparent'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-center text-xs font-medium">
|
||||
{pickAppText(locale, '示意二维码(Fake QR)', 'Illustrative QR (Fake QR)')}
|
||||
</p>
|
||||
<p className="mt-1 text-center text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '不可扫码,仅用于标识终端连接入口。', 'Not scannable; it only marks the terminal connection entry.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
{instructions.map((item) => (
|
||||
<div key={item} className="rounded-md border bg-muted/20 px-3 py-2">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TERMINAL_FAKE_QR_CELLS = [
|
||||
true, true, true, false, true, true, true,
|
||||
true, false, true, false, true, false, true,
|
||||
true, true, true, false, true, true, true,
|
||||
false, false, false, true, false, true, false,
|
||||
true, false, true, false, true, false, true,
|
||||
false, true, false, true, false, true, false,
|
||||
true, false, true, true, false, false, true,
|
||||
];
|
||||
|
||||
function terminalConnectionGuide(locale: AppLocale): string[] {
|
||||
return [
|
||||
pickAppText(locale, '保持本实例页面在线,终端客户端会通过 WebSocket 连接 Beaver。', 'Keep this instance online; the terminal client connects to Beaver over WebSocket.'),
|
||||
pickAppText(locale, '连接成功后,通道状态会显示 running,并显示当前 connected peers 数量。', 'After connection succeeds, the channel status shows running and the connected peers count is updated.'),
|
||||
pickAppText(locale, '这里的二维码是 fake 的说明图,不代表真实扫码绑定流程。', 'The QR shown here is fake guidance artwork, not a real scan-to-bind flow.'),
|
||||
];
|
||||
}
|
||||
|
||||
function terminalFallbackChannel(locale: AppLocale): ChannelStatus {
|
||||
return {
|
||||
channel_id: 'terminal',
|
||||
kind: 'terminal',
|
||||
mode: 'websocket',
|
||||
display_name: pickAppText(locale, '小终端', 'Terminal'),
|
||||
enabled: false,
|
||||
state: 'disabled',
|
||||
account_id: 'local',
|
||||
capabilities: ['receive_text', 'send_text'],
|
||||
connected_peers: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function providerLabel(provider: ProviderStatus): string {
|
||||
return provider.label || provider.name;
|
||||
}
|
||||
|
||||
function providerIdentity(provider: ProviderStatus): string {
|
||||
const identity = (provider.id || provider.name || provider.label || '').trim().toLowerCase();
|
||||
if (identity === 'vllm/local') return 'vllm';
|
||||
return identity;
|
||||
}
|
||||
|
||||
function visibleProvider(provider: ProviderStatus): boolean {
|
||||
return VISIBLE_PROVIDER_IDS.has(providerIdentity(provider));
|
||||
}
|
||||
|
||||
function connectorConnectionForKind(
|
||||
kind: string,
|
||||
connections: ChannelConnectionView[]
|
||||
): ChannelConnectionView | undefined {
|
||||
const matches = connections.filter((connection) => connection.kind === kind && connection.status !== 'revoked');
|
||||
return (
|
||||
matches.find((connection) => connection.status === 'connected') ||
|
||||
matches.find((connection) => connection.status !== 'error') ||
|
||||
matches[0]
|
||||
);
|
||||
}
|
||||
|
||||
function connectorCardSubtitle(
|
||||
connector: ChannelConnectorDescriptor,
|
||||
channel: ChannelStatus | undefined,
|
||||
connection: ChannelConnectionView | undefined,
|
||||
locale: AppLocale
|
||||
): string {
|
||||
if (channel?.state === 'running' || connection?.status === 'connected') {
|
||||
return pickAppText(locale, '已连接', 'Connected');
|
||||
}
|
||||
if (channel) return channel.state;
|
||||
if (connection) return connection.status;
|
||||
if (connector.kind === 'terminal') return pickAppText(locale, '本地终端连接', 'Local terminal connection');
|
||||
return connectorAuthLabel(connector, locale);
|
||||
}
|
||||
|
||||
function connectorCardChannelLabel(
|
||||
channel: ChannelStatus | undefined,
|
||||
connection: ChannelConnectionView | undefined,
|
||||
locale: AppLocale
|
||||
): string {
|
||||
const rawChannelId = connection?.channel_id || channel?.channel_id || '';
|
||||
const channelId = compactMainSuffix(rawChannelId);
|
||||
const accountId = compactMainSuffix(connection?.account_id || channel?.account_id || '');
|
||||
const mode = connection?.mode || channel?.mode || '';
|
||||
const parts = [channelId, mode, accountId].filter(Boolean);
|
||||
if (!channel && !connection) return pickAppText(locale, '连接方式说明', 'Connection instructions');
|
||||
if (parts.length === 0) return pickAppText(locale, '通道已连接', 'Channel connected');
|
||||
return `${pickAppText(locale, '通道', 'Channel')}: ${parts.join(' · ')}`;
|
||||
}
|
||||
|
||||
function compactMainSuffix(value: string): string {
|
||||
return value.replace(/[-_]?main$/i, '').trim();
|
||||
}
|
||||
|
||||
function connectorCardBadgeVariant(
|
||||
channel: ChannelStatus | undefined,
|
||||
connection: ChannelConnectionView | undefined
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
if (channel?.state === 'running' || connection?.status === 'connected') return 'default';
|
||||
if (channel?.state === 'error' || channel?.state === 'degraded' || connection?.status === 'error') {
|
||||
return 'destructive';
|
||||
}
|
||||
if (channel?.state === 'disabled' || channel?.state === 'stopped' || connection?.status === 'revoked') {
|
||||
return 'secondary';
|
||||
}
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function connectorCardBadgeLabel(
|
||||
connector: ChannelConnectorDescriptor,
|
||||
channel: ChannelStatus | undefined,
|
||||
connection: ChannelConnectionView | undefined,
|
||||
locale: AppLocale
|
||||
): string {
|
||||
if (channel?.state === 'running' || connection?.status === 'connected') return 'running';
|
||||
if (channel) return channel.state;
|
||||
if (connection) return connection.status;
|
||||
if (connector.kind === 'terminal') return pickAppText(locale, '说明', 'Guide');
|
||||
return pickAppText(locale, '连接', 'Connect');
|
||||
}
|
||||
|
||||
function connectorDisplayName(connector: Pick<ChannelConnectorDescriptor, 'kind' | 'displayName' | 'display_name'>): string {
|
||||
if (connector.displayName || connector.display_name) return connector.displayName || connector.display_name || connector.kind;
|
||||
if (connector.kind === 'weixin') return 'Weixin';
|
||||
if (connector.kind === 'feishu') return 'Feishu/Lark';
|
||||
if (connector.kind === 'terminal') return 'Terminal';
|
||||
if (connector.kind === 'telegram') return 'Telegram';
|
||||
return connector.kind;
|
||||
}
|
||||
@ -1187,6 +1482,7 @@ function connectorAuthLabel(connector: ChannelConnectorDescriptor, locale: AppLo
|
||||
const authType = connector.authType || connector.auth_type;
|
||||
if (connector.kind === 'weixin') return authType || pickAppText(locale, 'QR', 'QR');
|
||||
if (connector.kind === 'feishu') return authType || pickAppText(locale, '插件', 'Plugin');
|
||||
if (connector.kind === 'terminal') return pickAppText(locale, 'Fake QR', 'Fake QR');
|
||||
if (connector.kind === 'telegram') return authType || pickAppText(locale, 'Token', 'Token');
|
||||
return authType || connector.kind;
|
||||
}
|
||||
@ -1217,19 +1513,10 @@ function connectorSessionBadgeVariant(status: string): 'default' | 'secondary' |
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function channelStateBadgeVariant(
|
||||
state: ChannelStatus['state']
|
||||
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
if (state === 'running') return 'default';
|
||||
if (state === 'error' || state === 'degraded') return 'destructive';
|
||||
if (state === 'disabled' || state === 'stopped') return 'secondary';
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
function Field({ id, label, children }: { id?: string; label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label>{label}</Label>
|
||||
<div className="grid min-w-0 gap-2">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -19,7 +19,7 @@ import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import { shouldPollTaskDetail, taskDetailDurationMs } from '@/lib/task-detail-refresh';
|
||||
import { buildTaskTimelineCards } from '@/lib/task-timeline';
|
||||
import { buildTaskTimelineView } from '@/lib/task-timeline-view';
|
||||
import type { BackendTask } from '@/types';
|
||||
|
||||
const TERMINAL_TASK_STATUSES = new Set(['closed', 'abandoned', 'cancelled', 'error']);
|
||||
@ -45,6 +45,7 @@ export default function TaskDetailPage() {
|
||||
const mountedRef = React.useRef(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
@ -89,44 +90,17 @@ export default function TaskDetailPage() {
|
||||
return () => window.clearInterval(id);
|
||||
}, [backendTask, loadBackendTask]);
|
||||
|
||||
const taskRunIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const run of backendTask?.process_runs ?? []) ids.add(run.run_id);
|
||||
for (const runId of backendTask?.run_ids ?? []) ids.add(runId);
|
||||
return ids;
|
||||
}, [backendTask]);
|
||||
|
||||
const liveRuns = useMemo(
|
||||
() => processRuns.filter((run) => taskRunIds.has(run.run_id) || run.metadata?.task_id === taskId),
|
||||
[processRuns, taskId, taskRunIds]
|
||||
);
|
||||
|
||||
const liveEvents = useMemo(
|
||||
() => processEvents.filter((event) => taskRunIds.has(event.run_id) || event.metadata?.task_id === taskId),
|
||||
[processEvents, taskId, taskRunIds]
|
||||
);
|
||||
|
||||
const liveArtifacts = useMemo(
|
||||
() => processArtifacts.filter((artifact) => taskRunIds.has(artifact.run_id) || artifact.metadata?.task_id === taskId),
|
||||
[processArtifacts, taskId, taskRunIds]
|
||||
);
|
||||
|
||||
const renderedRuns = liveRuns.length > 0 ? liveRuns : backendTask?.process_runs ?? [];
|
||||
const renderedEvents = liveEvents.length > 0 ? liveEvents : backendTask?.process_events ?? [];
|
||||
const renderedArtifacts = liveArtifacts.length > 0 ? liveArtifacts : backendTask?.process_artifacts ?? [];
|
||||
|
||||
const timelineCards = useMemo(
|
||||
const timelineView = useMemo(
|
||||
() =>
|
||||
backendTask
|
||||
? buildTaskTimelineCards({
|
||||
buildTaskTimelineView({
|
||||
task: backendTask,
|
||||
processRuns: renderedRuns,
|
||||
processEvents: renderedEvents,
|
||||
processArtifacts: renderedArtifacts,
|
||||
})
|
||||
: [],
|
||||
[backendTask, renderedArtifacts, renderedEvents, renderedRuns]
|
||||
liveRuns: processRuns,
|
||||
liveEvents: processEvents,
|
||||
liveArtifacts: processArtifacts,
|
||||
}),
|
||||
[backendTask, processArtifacts, processEvents, processRuns]
|
||||
);
|
||||
const timelineCards = timelineView?.cards ?? [];
|
||||
|
||||
const activeLabel =
|
||||
[...timelineCards].reverse().find((card) => !['acceptance', 'task_created'].includes(card.type))?.title ?? '-';
|
||||
@ -164,13 +138,13 @@ export default function TaskDetailPage() {
|
||||
<div className="min-h-screen bg-background">
|
||||
<TaskLiveHeader task={backendTask} activeLabel={activeLabel} durationMs={durationMs} reviewTargetId={TASK_RESULT_REVIEW_ID} />
|
||||
|
||||
<main className="mx-auto grid max-w-7xl gap-6 p-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="space-y-4">
|
||||
<main className="mx-auto grid min-w-0 max-w-7xl gap-6 p-4 sm:p-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="min-w-0 space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
className="h-11 text-destructive hover:text-destructive"
|
||||
disabled={Boolean(actionBusy)}
|
||||
onClick={() => void deleteCurrentBackendTask()}
|
||||
>
|
||||
@ -217,7 +191,12 @@ export default function TaskDetailPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TaskSideRail task={backendTask} runs={renderedRuns} artifacts={renderedArtifacts} cards={timelineCards} />
|
||||
<TaskSideRail
|
||||
task={backendTask}
|
||||
runs={timelineView?.process.runs ?? []}
|
||||
artifacts={timelineView?.process.artifacts ?? []}
|
||||
cards={timelineCards}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
@ -225,7 +204,7 @@ export default function TaskDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-4 p-6">
|
||||
<Button asChild variant="outline" className="w-fit">
|
||||
<Button asChild variant="outline" className="h-11 w-fit">
|
||||
<Link href="/tasks">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '返回任务列表', 'Back to tasks')}
|
||||
|
||||
@ -46,6 +46,7 @@ export default function TasksPage() {
|
||||
function OrdinaryTasks() {
|
||||
const { locale } = useAppI18n();
|
||||
const [backendTasks, setBackendTasks] = useState<BackendTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const visibleTasks = useMemo(
|
||||
@ -58,17 +59,25 @@ function OrdinaryTasks() {
|
||||
|
||||
const loadBackendTasks = React.useCallback(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
listBackendTasks()
|
||||
.then((items) => {
|
||||
if (!cancelled) setBackendTasks(Array.isArray(items) ? items : []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setBackendTasks([]);
|
||||
.catch((err: any) => {
|
||||
if (!cancelled) {
|
||||
setBackendTasks([]);
|
||||
setError(err.message || pickAppText(locale, '加载任务失败', 'Failed to load tasks'));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => loadBackendTasks(), [loadBackendTasks]);
|
||||
|
||||
@ -86,7 +95,18 @@ function OrdinaryTasks() {
|
||||
}
|
||||
};
|
||||
|
||||
if (visibleTasks.length === 0) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{pickAppText(locale, '加载任务中', 'Loading tasks')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (visibleTasks.length === 0 && !error) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
@ -113,7 +133,19 @@ function OrdinaryTasks() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Card>
|
||||
{visibleTasks.length > 0 ? (
|
||||
<>
|
||||
<div className="grid gap-3 xl:hidden">
|
||||
{visibleTasks.map((task) => (
|
||||
<OrdinaryTaskCard
|
||||
key={task.task_id}
|
||||
task={task}
|
||||
locale={locale}
|
||||
onDelete={() => void handleDeleteBackendTask(task)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Card className="hidden xl:block">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -154,7 +186,7 @@ function OrdinaryTasks() {
|
||||
<TableCell className="text-xs text-muted-foreground">{formatTaskRuntimeTime(task.updated_at, locale)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Button asChild size="sm" variant="outline" className="h-11">
|
||||
<Link href={`/tasks/${encodeURIComponent(task.task_id)}`}>
|
||||
{pickAppText(locale, '进入', 'Open')}
|
||||
<ArrowRight className="ml-2 h-3.5 w-3.5" />
|
||||
@ -163,8 +195,9 @@ function OrdinaryTasks() {
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
className="h-11 w-11 text-destructive hover:text-destructive"
|
||||
onClick={() => void handleDeleteBackendTask(task)}
|
||||
aria-label={pickAppText(locale, `删除任务 ${task.short_title || task.task_id}`, `Delete task ${task.short_title || task.task_id}`)}
|
||||
title={pickAppText(locale, '删除任务', 'Delete task')}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
@ -177,10 +210,80 @@ function OrdinaryTasks() {
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrdinaryTaskCard({
|
||||
task,
|
||||
locale,
|
||||
onDelete,
|
||||
}: {
|
||||
task: BackendTask;
|
||||
locale: 'zh-CN' | 'en-US';
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const title = task.short_title || String(task.metadata?.short_title || '') || task.description || task.goal || task.task_id;
|
||||
|
||||
return (
|
||||
<Card className="rounded-md">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h2 className="min-w-0 flex-1 text-base font-semibold">{title}</h2>
|
||||
{task.is_open ? <Badge variant="secondary">{pickAppText(locale, '进行中', 'Active')}</Badge> : null}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||
{task.description || task.session_id}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-xs">
|
||||
<div>
|
||||
<div className="text-muted-foreground">{pickAppText(locale, '状态', 'Status')}</div>
|
||||
<Badge className="mt-1" variant={task.status === 'awaiting_acceptance' || task.status === 'closed' ? 'default' : 'secondary'}>
|
||||
{taskStatusLabel(task.status, locale)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">{pickAppText(locale, '来源', 'Source')}</div>
|
||||
<div className="mt-1 text-sm">{taskSourceLabel(task, locale)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">{pickAppText(locale, '运行 / 技能', 'Runs / skills')}</div>
|
||||
<div className="mt-1 text-sm">{task.run_ids.length} / {task.skill_names.length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">{pickAppText(locale, '更新时间', 'Updated')}</div>
|
||||
<div className="mt-1 text-sm">{formatTaskRuntimeTime(task.updated_at, locale)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border pt-3">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-11 w-11 text-destructive hover:text-destructive"
|
||||
onClick={onDelete}
|
||||
aria-label={pickAppText(locale, `删除任务 ${title}`, `Delete task ${title}`)}
|
||||
title={pickAppText(locale, '删除任务', 'Delete task')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="h-11">
|
||||
<Link href={`/tasks/${encodeURIComponent(task.task_id)}`}>
|
||||
{pickAppText(locale, '进入任务', 'Open task')}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function taskStatusLabel(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
const labels: Record<string, [string, string]> = {
|
||||
open: ['已创建', 'Open'],
|
||||
@ -246,6 +349,13 @@ function ScheduledTasks() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveJob = (job: CronJob) => {
|
||||
if (!window.confirm(pickAppText(locale, `删除定时任务“${job.name}”?`, `Delete scheduled task "${job.name}"?`))) {
|
||||
return;
|
||||
}
|
||||
void runJobAction(() => removeCronJob(job.id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@ -254,11 +364,11 @@ function ScheduledTasks() {
|
||||
{pickAppText(locale, '每次触发会生成通知记录;需要修改时再接入 Task。', 'Each trigger creates a notification record; connect it to a Task when revision is needed.')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={() => void loadJobs()} variant="outline" size="sm">
|
||||
<Button onClick={() => void loadJobs()} variant="outline" size="sm" className="h-11">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '刷新', 'Refresh')}
|
||||
</Button>
|
||||
<Button onClick={() => setShowAdd(true)} size="sm">
|
||||
<Button onClick={() => setShowAdd(true)} size="sm" className="h-11">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '新建定时任务', 'New scheduled task')}
|
||||
</Button>
|
||||
@ -287,7 +397,23 @@ function ScheduledTasks() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
{!loading && jobs.length > 0 ? (
|
||||
<div className="grid gap-3 xl:hidden">
|
||||
{jobs.map((job) => (
|
||||
<ScheduledJobCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
locale={locale}
|
||||
formatTime={formatTime}
|
||||
onToggle={(checked) => void runJobAction(() => toggleCronJob(job.id, checked))}
|
||||
onRun={() => void runJobAction(() => runCronJob(job.id))}
|
||||
onRemove={() => handleRemoveJob(job)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Card className={!loading && jobs.length > 0 ? 'hidden xl:block' : undefined}>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||
@ -316,7 +442,11 @@ function ScheduledTasks() {
|
||||
{jobs.map((job) => (
|
||||
<TableRow key={job.id}>
|
||||
<TableCell>
|
||||
<Switch checked={job.enabled} onCheckedChange={(checked) => void runJobAction(() => toggleCronJob(job.id, checked))} />
|
||||
<Switch
|
||||
checked={job.enabled}
|
||||
onCheckedChange={(checked) => void runJobAction(() => toggleCronJob(job.id, checked))}
|
||||
aria-label={pickAppText(locale, `切换定时任务 ${job.name}`, `Toggle scheduled task ${job.name}`)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">{job.name}</div>
|
||||
@ -330,7 +460,7 @@ function ScheduledTasks() {
|
||||
<span className="block max-w-[260px] truncate text-sm">{job.message}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button asChild size="sm" variant="outline" disabled={!job.last_scheduled_run_id && !job.last_task_id}>
|
||||
<Button asChild size="sm" variant="outline" className="h-11" disabled={!job.last_scheduled_run_id && !job.last_task_id}>
|
||||
<Link href={job.last_scheduled_run_id ? `/notifications/${encodeURIComponent(job.last_scheduled_run_id)}` : job.last_task_id ? `/tasks/${encodeURIComponent(job.last_task_id)}` : '/tasks'}>
|
||||
<FolderDown className="mr-2 h-3.5 w-3.5" />
|
||||
{formatTime(job.last_run_at_ms)}
|
||||
@ -348,10 +478,24 @@ function ScheduledTasks() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => void runJobAction(() => runCronJob(job.id))}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
onClick={() => void runJobAction(() => runCronJob(job.id))}
|
||||
aria-label={pickAppText(locale, `立即运行 ${job.name}`, `Run ${job.name} now`)}
|
||||
title={pickAppText(locale, '立即运行', 'Run now')}
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={() => void runJobAction(() => removeCronJob(job.id))}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11 text-destructive hover:text-destructive"
|
||||
onClick={() => handleRemoveJob(job)}
|
||||
aria-label={pickAppText(locale, `删除定时任务 ${job.name}`, `Delete scheduled task ${job.name}`)}
|
||||
title={pickAppText(locale, '删除定时任务', 'Delete scheduled task')}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@ -367,6 +511,112 @@ function ScheduledTasks() {
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduledJobCard({
|
||||
job,
|
||||
locale,
|
||||
formatTime,
|
||||
onToggle,
|
||||
onRun,
|
||||
onRemove,
|
||||
}: {
|
||||
job: CronJob;
|
||||
locale: 'zh-CN' | 'en-US';
|
||||
formatTime: (ms: number | null) => string;
|
||||
onToggle: (checked: boolean) => void;
|
||||
onRun: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const historyHref = job.last_scheduled_run_id
|
||||
? `/notifications/${encodeURIComponent(job.last_scheduled_run_id)}`
|
||||
: job.last_task_id
|
||||
? `/tasks/${encodeURIComponent(job.last_task_id)}`
|
||||
: '/tasks';
|
||||
|
||||
return (
|
||||
<Card className="rounded-md">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold">{job.name}</h2>
|
||||
<p className="mt-1 break-all text-xs text-muted-foreground">{job.id}</p>
|
||||
</div>
|
||||
<label className="flex min-h-11 shrink-0 items-center gap-2 text-sm">
|
||||
<span className="sr-only">{pickAppText(locale, '启用', 'Enabled')}</span>
|
||||
<Switch
|
||||
checked={job.enabled}
|
||||
onCheckedChange={onToggle}
|
||||
aria-label={pickAppText(locale, `切换定时任务 ${job.name}`, `Toggle scheduled task ${job.name}`)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p className="text-sm leading-6 text-muted-foreground">{job.message}</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-xs">
|
||||
<div>
|
||||
<div className="text-muted-foreground">{pickAppText(locale, '计划', 'Schedule')}</div>
|
||||
<code className="mt-1 inline-block rounded bg-muted px-1.5 py-0.5">{job.schedule_display}</code>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">{pickAppText(locale, '下次运行', 'Next run')}</div>
|
||||
<div className="mt-1 text-sm">{formatTime(job.next_run_at_ms)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">{pickAppText(locale, '上次运行', 'Last run')}</div>
|
||||
<div className="mt-1 text-sm">{formatTime(job.last_run_at_ms)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">{pickAppText(locale, '状态', 'Status')}</div>
|
||||
<div className="mt-1">
|
||||
{job.last_status === 'ok' ? (
|
||||
<Badge>{pickAppText(locale, '成功', 'OK')}</Badge>
|
||||
) : job.last_status === 'error' ? (
|
||||
<Badge variant="destructive">{pickAppText(locale, '错误', 'Error')}</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-3">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="h-11"
|
||||
disabled={!job.last_scheduled_run_id && !job.last_task_id}
|
||||
>
|
||||
<Link href={historyHref}>
|
||||
<FolderDown className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '运行历史', 'History')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
onClick={onRun}
|
||||
aria-label={pickAppText(locale, `立即运行 ${job.name}`, `Run ${job.name} now`)}
|
||||
title={pickAppText(locale, '立即运行', 'Run now')}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11 text-destructive hover:text-destructive"
|
||||
onClick={onRemove}
|
||||
aria-label={pickAppText(locale, `删除定时任务 ${job.name}`, `Delete scheduled task ${job.name}`)}
|
||||
title={pickAppText(locale, '删除定时任务', 'Delete scheduled task')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AddJobForm({
|
||||
targetSessionKey,
|
||||
onAdd,
|
||||
@ -403,7 +653,14 @@ function AddJobForm({
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">{pickAppText(locale, '新建定时任务', 'New scheduled task')}</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11"
|
||||
onClick={onCancel}
|
||||
aria-label={pickAppText(locale, '关闭新建定时任务表单', 'Close new scheduled task form')}
|
||||
title={pickAppText(locale, '关闭', 'Close')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@ -452,8 +709,8 @@ function AddJobForm({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>{pickAppText(locale, '取消', 'Cancel')}</Button>
|
||||
<Button type="submit" disabled={!name.trim() || !message.trim()}>
|
||||
<Button type="button" variant="outline" className="h-11" onClick={onCancel}>{pickAppText(locale, '取消', 'Cancel')}</Button>
|
||||
<Button type="submit" className="h-11" disabled={!name.trim() || !message.trim()}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '创建', 'Create')}
|
||||
</Button>
|
||||
|
||||
@ -4,6 +4,7 @@ import React from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { getStatus, listSessions, wsManager } from '@/lib/api';
|
||||
import { runtimeBridgeEnabledForPath } from '@/lib/channel-connector-state';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import type { ProcessWsEvent, SessionUpdatedEvent, WsEvent } from '@/types';
|
||||
|
||||
@ -47,7 +48,7 @@ export function AppRuntimeBridge() {
|
||||
const ingestProcessEvent = useChatStore((state) => state.ingestProcessEvent);
|
||||
const statusCheckCleanupRef = React.useRef<(() => void) | null>(null);
|
||||
const statusCheckInFlightRef = React.useRef(false);
|
||||
const chatRuntimeEnabled = pathname === '/' || pathname.startsWith('/tasks') || pathname.startsWith('/notifications');
|
||||
const chatRuntimeEnabled = runtimeBridgeEnabledForPath(pathname);
|
||||
|
||||
const loadSessions = React.useCallback(async () => {
|
||||
try {
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Bell, Bot, ChevronDown, FolderOpen, ListTodo, LogOut, Mail, MessageSquare, Puzzle, Settings, Store, Wrench } from 'lucide-react';
|
||||
import { Bell, Bot, ChevronDown, FolderOpen, ListTodo, LogOut, Mail, Menu, MessageSquare, Puzzle, Settings, Store, Wrench, X } from 'lucide-react';
|
||||
import { logout } from '@/lib/api';
|
||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
@ -69,6 +69,7 @@ const Header = () => {
|
||||
const { locale } = useAppI18n();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
|
||||
const user = useChatStore((s) => s.user);
|
||||
const isAuthLoading = useChatStore((s) => s.isAuthLoading);
|
||||
const setUser = useChatStore((s) => s.setUser);
|
||||
@ -93,20 +94,33 @@ const Header = () => {
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
setMobileMenuOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mobileMenuOpen) return;
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [mobileMenuOpen]);
|
||||
|
||||
const userInitial = (user?.username || user?.email || '?').trim().charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<header className="fixed left-0 right-0 top-0 z-50 border-b border-[#E6E1DE] bg-[#F7F6F5]/95 backdrop-blur">
|
||||
<div className="mx-auto max-w-[1720px] px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid h-16 grid-cols-[minmax(120px,1fr)_auto_minmax(120px,1fr)] items-center gap-4">
|
||||
<Link href="/" className="flex shrink-0 items-center">
|
||||
<span className="font-serif text-[28px] font-semibold leading-none text-[#0B0B0B]">
|
||||
Beaver
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-1 rounded-full border border-[#E6E1DE] bg-white px-1.5 py-1 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const renderNavLinks = (compact = false) =>
|
||||
NAV_ITEMS.map((item) => {
|
||||
const isActive =
|
||||
item.href === '/'
|
||||
? pathname === '/'
|
||||
@ -116,21 +130,52 @@ const Header = () => {
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex shrink-0 items-center gap-1.5 rounded-full px-4 py-2 text-sm font-medium transition-colors ${
|
||||
onClick={compact ? () => setMobileMenuOpen(false) : undefined}
|
||||
className={`flex h-11 shrink-0 items-center gap-2 rounded-full text-sm font-medium transition-colors ${
|
||||
compact ? 'justify-start rounded-lg border border-transparent bg-background px-4' : 'px-4'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: compact
|
||||
? 'text-[#4F4642] hover:border-[#E6E1DE] hover:bg-muted hover:text-[#0B0B0B]'
|
||||
: 'text-[#4F4642] hover:bg-[#F7F5F4] hover:text-[#0B0B0B]'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<Icon className="h-4 w-4" />
|
||||
{navLabel(item.key)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="fixed left-0 right-0 top-0 z-50 border-b border-[#E6E1DE] bg-[#F7F6F5]/95 backdrop-blur">
|
||||
<div className="mx-auto max-w-[1720px] px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-[#E6E1DE] bg-white text-[#1D1715] transition-colors hover:bg-[#F7F5F4] 2xl:hidden"
|
||||
aria-label={mobileMenuOpen ? pickAppText(locale, '关闭导航', 'Close navigation') : pickAppText(locale, '打开导航', 'Open navigation')}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-controls="app-primary-mobile-nav"
|
||||
onClick={() => setMobileMenuOpen((open) => !open)}
|
||||
>
|
||||
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
<Link href="/" className="hidden h-11 shrink-0 items-center min-[360px]:flex">
|
||||
<span className="font-serif text-[26px] font-semibold leading-none text-[#0B0B0B] sm:text-[28px]">
|
||||
Beaver
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="hidden items-center gap-1 rounded-full border border-[#E6E1DE] bg-white px-1.5 py-1 shadow-[0_1px_2px_rgba(0,0,0,0.04)] 2xl:flex">
|
||||
{renderNavLinks(false)}
|
||||
</nav>
|
||||
|
||||
<div className="flex min-w-0 items-center justify-end gap-3">
|
||||
<div className="hidden shrink-0 sm:block">
|
||||
<div className="flex min-w-0 shrink-0 items-center justify-end gap-2 sm:gap-3">
|
||||
<div className="hidden shrink-0 xl:block">
|
||||
<ConnectionDot />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
@ -140,7 +185,8 @@ const Header = () => {
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-full border border-[#E6E1DE] bg-white px-2 py-1.5 text-sm font-medium text-[#1D1715] transition-colors hover:bg-[#F7F5F4]"
|
||||
className="flex h-11 w-11 items-center justify-center gap-2 rounded-full border border-[#E6E1DE] bg-white px-1.5 text-sm font-medium text-[#1D1715] transition-colors hover:bg-[#F7F5F4] sm:w-auto sm:justify-start sm:px-2"
|
||||
aria-label={pickAppText(locale, '打开账号菜单', 'Open account menu')}
|
||||
>
|
||||
<Avatar className="h-8 w-8 border border-[#E6E1DE]">
|
||||
<AvatarFallback className="bg-primary text-xs font-semibold text-primary-foreground">
|
||||
@ -148,7 +194,7 @@ const Header = () => {
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden max-w-28 truncate sm:block">{user.username}</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
<ChevronDown className="hidden h-4 w-4 text-muted-foreground sm:block" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 rounded-3xl border-border/70 p-0 shadow-2xl">
|
||||
@ -195,6 +241,28 @@ const Header = () => {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{mobileMenuOpen && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-x-0 bottom-0 top-16 z-40 bg-black/40 2xl:hidden"
|
||||
aria-label={pickAppText(locale, '关闭导航', 'Close navigation')}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
<nav
|
||||
id="app-primary-mobile-nav"
|
||||
aria-label={pickAppText(locale, '主导航', 'Primary navigation')}
|
||||
className="fixed bottom-0 left-0 top-16 z-[45] isolate w-[min(86vw,320px)] overflow-y-auto border-r border-[#E6E1DE] bg-background text-foreground shadow-[12px_0_32px_rgba(29,23,21,0.24)] animate-in slide-in-from-left-full duration-200 2xl:hidden"
|
||||
>
|
||||
<div className="min-h-full bg-background px-4 py-5">
|
||||
<div className="grid gap-2 bg-background">
|
||||
{renderNavLinks(true)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ export function LanguageSwitcher({ className }: { className?: string }) {
|
||||
type="button"
|
||||
onClick={() => setLocale(option.value)}
|
||||
className={cn(
|
||||
'rounded px-2 py-1 text-xs font-medium transition-colors',
|
||||
'h-11 w-11 rounded text-xs font-medium transition-colors',
|
||||
locale === option.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
|
||||
@ -1,254 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
FileJson,
|
||||
FileOutput,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Link2,
|
||||
ListChecks,
|
||||
Loader2,
|
||||
PanelRightOpen,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Activity, PanelRightOpen, X } from 'lucide-react';
|
||||
|
||||
import { TaskTimeline } from '@/components/task-detail';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { appStatusLabel } from '@/lib/i18n/common';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type {
|
||||
SessionProgressArtifactView,
|
||||
SessionProgressStepView,
|
||||
SessionProgressView,
|
||||
} from '@/lib/session-progress';
|
||||
import type { ProcessArtifact, ProcessRunStatus } from '@/types';
|
||||
|
||||
function formatShortTime(value: string, locale: 'zh-CN' | 'en-US') {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function statusTone(status: ProcessRunStatus) {
|
||||
if (status === 'done') return 'text-[#2F8D50] bg-[#E3F1E7] border-[#B8D9C2]';
|
||||
if (status === 'running') return 'text-[#2F6FCA] bg-[#E7EEF9] border-[#B8CBE8]';
|
||||
if (status === 'error') return 'text-[#8A3A2D] bg-[#F0E5E1] border-[#D9BDB4]';
|
||||
if (status === 'cancelled') return 'text-[#6A5E58] bg-[#ECE8E5] border-[#D8D2CE]';
|
||||
return 'text-[#6A5E58] bg-[#F0ECE9] border-[#D8D2CE]';
|
||||
}
|
||||
|
||||
function StepMarker({ step, index }: { step: SessionProgressStepView; index: number }) {
|
||||
if (step.status === 'done') {
|
||||
return (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#2F8D50] text-white">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (step.status === 'running') {
|
||||
return (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#2F6FCA] text-[11px] font-semibold text-white">
|
||||
{index + 1}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (step.status === 'error') {
|
||||
return (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#8A3A2D] text-white">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#D8D2CE] text-[#6A5E58]">
|
||||
<Circle className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function artifactIcon(type: ProcessArtifact['artifact_type']) {
|
||||
if (type === 'json') return <FileJson className="h-4 w-4" />;
|
||||
if (type === 'image') return <ImageIcon className="h-4 w-4" />;
|
||||
if (type === 'link') return <Link2 className="h-4 w-4" />;
|
||||
if (type === 'markdown' || type === 'text') return <FileText className="h-4 w-4" />;
|
||||
return <FileOutput className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
function ProgressHeader({ view }: { view: SessionProgressView }) {
|
||||
const { locale } = useAppI18n();
|
||||
const percent = view.progress.percent;
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-[#ECE7E3] bg-white px-4 py-4 shadow-[0_8px_24px_rgba(0,0,0,0.04)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#E3F1E7] text-[#2F8D50]">
|
||||
<ListChecks className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="line-clamp-2 text-sm font-semibold text-foreground">{view.title}</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className={`rounded-full border px-2 py-0.5 text-[11px] font-medium ${statusTone(view.status)}`}>
|
||||
{appStatusLabel(view.status, locale)}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{pickAppText(locale, '更新于', 'Updated')} {formatShortTime(view.updatedAt, locale)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span>{view.progress.label}</span>
|
||||
{percent !== null && <span className="font-medium text-foreground">{percent}%</span>}
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-[#ECE8E5]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[#5DB56F] transition-all"
|
||||
style={{ width: `${percent ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view.summary && (
|
||||
<p className="mt-3 line-clamp-3 text-xs leading-5 text-muted-foreground">{view.summary}</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StepList({ steps }: { steps: SessionProgressStepView[] }) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-[#ECE7E3] bg-white px-4 py-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{pickAppText(locale, '运行步骤', 'Run Steps')}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, `${steps.length} 步`, `${steps.length} steps`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.runId} className="grid grid-cols-[24px_1fr] gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<StepMarker step={step} index={index} />
|
||||
{index < steps.length - 1 && <span className="mt-2 h-full min-h-8 w-px bg-[#E6E1DE]" />}
|
||||
</div>
|
||||
<div className="pb-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="line-clamp-2 text-sm font-medium text-foreground">
|
||||
{index + 1}. {step.title}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{step.actorName} · {formatShortTime(step.updatedAt, locale)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] ${statusTone(step.status)}`}>
|
||||
{appStatusLabel(step.status, locale)}
|
||||
</span>
|
||||
</div>
|
||||
{step.description && (
|
||||
<p className="mt-2 line-clamp-3 text-xs leading-5 text-muted-foreground">
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
{step.status === 'running' && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-[#2F6FCA]">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>{pickAppText(locale, '正在处理', 'In progress')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactRow({ artifact }: { artifact: SessionProgressArtifactView }) {
|
||||
return (
|
||||
<a
|
||||
href={artifact.url || undefined}
|
||||
target={artifact.url ? '_blank' : undefined}
|
||||
rel={artifact.url ? 'noreferrer' : undefined}
|
||||
className="block rounded-lg border border-[#ECE7E3] bg-[#FDFDFC] px-3 py-3 transition-colors hover:bg-[#F7F6F5]"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#ECE8E5] text-[#5F5550]">
|
||||
{artifactIcon(artifact.type)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{artifact.title}</div>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{artifact.actorName} · {artifact.typeLabel}
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-5 text-muted-foreground">{artifact.preview}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactSection({ view }: { view: SessionProgressView }) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-[#ECE7E3] bg-white px-4 py-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{pickAppText(locale, '生成内容', 'Generated Content')}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, `${view.artifacts.length} 个`, `${view.artifacts.length} items`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{view.artifactTypeSummaries.length > 0 ? (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{view.artifactTypeSummaries.map((item) => (
|
||||
<span
|
||||
key={item.type}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[#E6E1DE] bg-[#F7F6F5] px-2.5 py-1 text-xs text-[#4F4642]"
|
||||
>
|
||||
{artifactIcon(item.type)}
|
||||
<span>{item.label}</span>
|
||||
<span className="font-semibold">{item.count}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '暂时还没有生成内容。', 'No generated content yet.')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{view.artifacts.map((artifact) => (
|
||||
<ArtifactRow key={artifact.artifactId} artifact={artifact} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
import type { TaskTimelineCard } from '@/types';
|
||||
|
||||
function ProgressPanel({
|
||||
view,
|
||||
cards,
|
||||
isLive,
|
||||
onClose,
|
||||
}: {
|
||||
view: SessionProgressView;
|
||||
cards: TaskTimelineCard[];
|
||||
isLive: boolean;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
@ -260,11 +27,14 @@ function ProgressPanel({
|
||||
<h2 className="text-base font-semibold text-foreground">
|
||||
{pickAppText(locale, '当前会话的运行进度', 'Current Session Progress')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '任务列表会自动刷新', 'Task updates refresh automatically')}
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{isLive ? <Activity className="h-3.5 w-3.5" /> : null}
|
||||
{isLive
|
||||
? pickAppText(locale, '任务时间线实时更新', 'Task timeline updates live')
|
||||
: pickAppText(locale, '与任务详情时间线一致', 'Matches the Task detail timeline')}
|
||||
</p>
|
||||
</div>
|
||||
{onClose && (
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@ -273,28 +43,32 @@ function ProgressPanel({
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1 px-4 py-4">
|
||||
<div className="space-y-4 pb-6">
|
||||
<ProgressHeader view={view} />
|
||||
<StepList steps={view.steps} />
|
||||
<ArtifactSection view={view} />
|
||||
<div className="pb-6">
|
||||
<TaskTimeline cards={cards} isLive={isLive} showHeader={false} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CurrentSessionProgressSidebar({ view }: { view: SessionProgressView }) {
|
||||
export function CurrentSessionProgressSidebar({
|
||||
cards,
|
||||
isLive,
|
||||
}: {
|
||||
cards: TaskTimelineCard[];
|
||||
isLive: boolean;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="hidden h-full w-[380px] shrink-0 border-l border-[#E6E1DE] xl:flex">
|
||||
<ProgressPanel view={view} />
|
||||
<ProgressPanel cards={cards} isLive={isLive} />
|
||||
</aside>
|
||||
|
||||
<button
|
||||
@ -306,7 +80,7 @@ export function CurrentSessionProgressSidebar({ view }: { view: SessionProgressV
|
||||
<PanelRightOpen className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{mobileOpen && (
|
||||
{mobileOpen ? (
|
||||
<div className="fixed inset-0 z-50 xl:hidden">
|
||||
<button
|
||||
type="button"
|
||||
@ -315,10 +89,10 @@ export function CurrentSessionProgressSidebar({ view }: { view: SessionProgressV
|
||||
aria-label={pickAppText(locale, '关闭进度面板', 'Close progress panel')}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 w-[min(92vw,390px)] border-l border-[#E6E1DE] shadow-2xl">
|
||||
<ProgressPanel view={view} onClose={() => setMobileOpen(false)} />
|
||||
<ProgressPanel cards={cards} isLive={isLive} onClose={() => setMobileOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -10,6 +10,14 @@ import { getTaskCardMessageIndexes, hasVisibleChatContent, normalizedMessageText
|
||||
import { AgentTeamBlock } from '@/components/chat-workbench/AgentTeamBlock';
|
||||
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { containedPreservedLongTextClass } from '@/lib/text-wrapping';
|
||||
@ -58,6 +66,8 @@ function MessageBubble({
|
||||
const textContent = normalizedMessageText(message.content);
|
||||
const [feedbackMode, setFeedbackMode] = React.useState<'accept' | null>(null);
|
||||
const [feedbackComment, setFeedbackComment] = React.useState('');
|
||||
const [confirmAbandonOpen, setConfirmAbandonOpen] = React.useState(false);
|
||||
const feedbackTextareaId = message.run_id ? `feedback-note-${message.run_id}` : undefined;
|
||||
|
||||
return (
|
||||
<div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}>
|
||||
@ -130,7 +140,7 @@ function MessageBubble({
|
||||
</div>
|
||||
<Link
|
||||
href={`/tasks/${encodeURIComponent(message.task_id)}`}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
className="inline-flex h-11 items-center gap-1 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{pickAppText(locale, '查看任务', 'Open task')}
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
@ -157,7 +167,7 @@ function MessageBubble({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFeedbackMode('accept')}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
className="inline-flex h-11 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ThumbsUp className="h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '接受', 'Accept')}
|
||||
@ -165,15 +175,15 @@ function MessageBubble({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRequestRevision(message.run_id!)}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
className="inline-flex h-11 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RefreshCcw className="h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '需要修改', 'Revise')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFeedback(message.run_id!, 'abandon')}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
onClick={() => setConfirmAbandonOpen(true)}
|
||||
className="inline-flex h-11 items-center gap-1 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '放弃', 'Abandon')}
|
||||
@ -181,7 +191,11 @@ function MessageBubble({
|
||||
</div>
|
||||
{feedbackMode && (
|
||||
<div className="space-y-2 rounded-md border border-border bg-background p-2">
|
||||
<label htmlFor={feedbackTextareaId} className="sr-only">
|
||||
{pickAppText(locale, '接受反馈备注', 'Acceptance note')}
|
||||
</label>
|
||||
<textarea
|
||||
id={feedbackTextareaId}
|
||||
value={feedbackComment}
|
||||
onChange={(event) => setFeedbackComment(event.target.value)}
|
||||
placeholder={pickAppText(locale, '可选:补充说明...', 'Optional note...')}
|
||||
@ -194,14 +208,14 @@ function MessageBubble({
|
||||
setFeedbackMode(null);
|
||||
setFeedbackComment('');
|
||||
}}
|
||||
className="h-8 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent"
|
||||
className="h-11 rounded-md border border-border px-3 text-xs text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
{pickAppText(locale, '取消', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFeedback(message.run_id!, feedbackMode, feedbackComment.trim() || undefined)}
|
||||
className="h-8 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
className="h-11 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{pickAppText(locale, '提交', 'Submit')}
|
||||
</button>
|
||||
@ -210,6 +224,35 @@ function MessageBubble({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Dialog open={confirmAbandonOpen} onOpenChange={setConfirmAbandonOpen}>
|
||||
<DialogContent className="max-w-[calc(100vw-2rem)] sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{pickAppText(locale, '放弃当前任务?', 'Abandon this task?')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pickAppText(locale, '放弃后会停止等待该任务的验收结果,此操作需要明确确认。', 'This stops waiting for this task acceptance result and requires confirmation.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmAbandonOpen(false)}
|
||||
className="h-11 rounded-md border border-border px-4 text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
{pickAppText(locale, '取消', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setConfirmAbandonOpen(false);
|
||||
onFeedback(message.run_id!, 'abandon');
|
||||
}}
|
||||
className="h-11 rounded-md bg-destructive px-4 text-sm font-medium text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{pickAppText(locale, '确认放弃', 'Confirm abandon')}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{message.feedback_error && (
|
||||
<span className="text-xs text-destructive">{message.feedback_error}</span>
|
||||
)}
|
||||
@ -394,8 +437,8 @@ export function MessageList({
|
||||
})();
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full px-8" viewportRef={viewportRef}>
|
||||
<div className="mx-auto max-w-5xl space-y-8 py-10">
|
||||
<ScrollArea className="h-full px-3 sm:px-5 md:px-8" viewportRef={viewportRef}>
|
||||
<div className="mx-auto max-w-5xl space-y-8 py-6 md:py-10">
|
||||
{visibleMessages.length === 0 && teamGroups.length === 0 && !isThinking && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<Bot className="w-12 h-12 mb-4 opacity-50" />
|
||||
|
||||
@ -54,27 +54,27 @@ export function SkillDetailView({
|
||||
}: SkillDetailViewProps) {
|
||||
const readme = stripFrontmatter(content || '');
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-white text-black [--background:0_0%_100%] [--card:0_0%_100%] [--card-foreground:0_0%_0%] [--foreground:0_0%_0%] [--muted-foreground:0_0%_0%] [--popover:0_0%_100%] [--popover-foreground:0_0%_0%] [--secondary-foreground:0_0%_0%]">
|
||||
<div className="border-b border-border p-5">
|
||||
<div className="min-w-0 rounded-lg border border-border bg-white text-black [--background:0_0%_100%] [--card:0_0%_100%] [--card-foreground:0_0%_0%] [--foreground:0_0%_0%] [--muted-foreground:0_0%_0%] [--popover:0_0%_100%] [--popover-foreground:0_0%_0%] [--secondary-foreground:0_0%_0%]">
|
||||
<div className="border-b border-border p-4 sm:p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
{badges}
|
||||
<Badge variant="outline">v{currentVersion || '-'}</Badge>
|
||||
{loadingVersion && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
<h2 className="break-words text-2xl font-semibold tracking-normal">{title}</h2>
|
||||
{summary && <p className="mt-2 max-w-4xl text-sm leading-6 text-muted-foreground">{summary}</p>}
|
||||
{summary && <p className="mt-2 max-w-4xl break-words text-sm leading-6 text-muted-foreground">{summary}</p>}
|
||||
</div>
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="p-5">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">{labels.overview}</TabsTrigger>
|
||||
<TabsTrigger value="files">{labels.files}</TabsTrigger>
|
||||
<TabsTrigger value="versions">{labels.versions}</TabsTrigger>
|
||||
<Tabs defaultValue="overview" className="p-4 sm:p-5">
|
||||
<TabsList className="h-auto min-h-11 flex-wrap">
|
||||
<TabsTrigger value="overview" className="min-h-11">{labels.overview}</TabsTrigger>
|
||||
<TabsTrigger value="files" className="min-h-11">{labels.files}</TabsTrigger>
|
||||
<TabsTrigger value="versions" className="min-h-11">{labels.versions}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-5">
|
||||
@ -86,7 +86,7 @@ export function SkillDetailView({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="files" className="mt-5">
|
||||
<div className="grid gap-4 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<div className="grid min-w-0 gap-4 lg:grid-cols-[minmax(0,320px)_minmax(0,1fr)]">
|
||||
<div className="min-h-[420px] rounded-lg border border-border">
|
||||
{files.length === 0 ? (
|
||||
<EmptyPanel icon={<FileText className="h-7 w-7" />} text={labels.noFiles} />
|
||||
@ -96,7 +96,7 @@ export function SkillDetailView({
|
||||
<button
|
||||
key={file.filePath}
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between gap-3 rounded-md px-3 py-2 text-left text-sm transition hover:bg-muted ${
|
||||
className={`flex min-h-11 w-full items-center justify-between gap-3 rounded-md px-3 py-3 text-left text-sm transition hover:bg-muted ${
|
||||
selectedFile?.filePath === file.filePath ? 'bg-muted' : ''
|
||||
}`}
|
||||
onClick={() => onOpenFile(file.filePath)}
|
||||
@ -109,7 +109,7 @@ export function SkillDetailView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-[420px] rounded-lg border border-border bg-muted/20 p-4">
|
||||
<div className="min-w-0 rounded-lg border border-border bg-muted/20 p-4 lg:min-h-[420px]">
|
||||
{loadingFile ? (
|
||||
<div className="flex h-[360px] items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
@ -129,7 +129,7 @@ export function SkillDetailView({
|
||||
<button
|
||||
key={version.version}
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between gap-4 rounded-lg border px-4 py-3 text-left transition hover:bg-muted ${
|
||||
className={`flex min-h-11 w-full flex-col gap-3 rounded-lg border px-4 py-3 text-left transition hover:bg-muted sm:flex-row sm:items-center sm:justify-between sm:gap-4 ${
|
||||
version.version === currentVersion ? 'border-primary bg-muted' : 'border-border'
|
||||
}`}
|
||||
onClick={() => onSelectVersion(version.version)}
|
||||
@ -137,15 +137,15 @@ export function SkillDetailView({
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-mono text-sm">{version.version}</span>
|
||||
<span className="break-all font-mono text-sm">{version.version}</span>
|
||||
{version.version === currentVersion && <Badge variant="secondary">{labels.current}</Badge>}
|
||||
{version.status && <Badge variant="outline">{version.status}</Badge>}
|
||||
</div>
|
||||
{version.changeReason && (
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">{version.changeReason}</p>
|
||||
<p className="mt-1 break-words text-xs text-muted-foreground">{version.changeReason}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-xs text-muted-foreground">
|
||||
<div className="shrink-0 text-left text-xs text-muted-foreground sm:text-right">
|
||||
{version.publishedAt || version.createdAt || ''}
|
||||
{typeof version.totalSize === 'number' && (
|
||||
<div>
|
||||
@ -165,7 +165,7 @@ export function SkillDetailView({
|
||||
function FilePreview({ file, labels }: { file: SkillFileContent; labels: SkillDetailViewProps['labels'] }) {
|
||||
const content = file.content || '';
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="break-all font-mono text-sm font-medium">{file.filePath}</div>
|
||||
<Badge variant="outline">
|
||||
@ -177,7 +177,7 @@ function FilePreview({ file, labels }: { file: SkillFileContent; labels: SkillDe
|
||||
) : isMarkdown(file.filePath, file.contentType) ? (
|
||||
<MarkdownPreview content={stripFrontmatter(content)} />
|
||||
) : (
|
||||
<pre className="max-h-[560px] overflow-auto rounded-md border border-border bg-background p-4 text-xs leading-5">
|
||||
<pre className="max-h-[560px] max-w-full overflow-auto whitespace-pre-wrap break-words rounded-md border border-border bg-background p-4 text-xs leading-5">
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
@ -187,7 +187,7 @@ function FilePreview({ file, labels }: { file: SkillFileContent; labels: SkillDe
|
||||
|
||||
function MarkdownPreview({ content }: { content: string }) {
|
||||
return (
|
||||
<div className="prose prose-sm max-w-none text-black prose-a:text-black prose-blockquote:text-black prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:text-black prose-headings:text-black prose-headings:tracking-normal prose-li:text-black prose-p:text-black prose-pre:border prose-pre:border-border prose-pre:bg-muted prose-pre:text-black prose-strong:text-black prose-table:text-black prose-td:text-black prose-th:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
<div className="prose prose-sm max-w-none break-words text-black prose-a:text-black prose-blockquote:text-black prose-code:break-words prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:text-black prose-headings:text-black prose-headings:tracking-normal prose-li:text-black prose-p:text-black prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:border prose-pre:border-border prose-pre:bg-muted prose-pre:text-black prose-strong:text-black prose-table:block prose-table:overflow-x-auto prose-table:text-black prose-td:text-black prose-th:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -7,6 +7,7 @@ import { TaskRuntimeStatusBadge, formatTaskRuntimeTime } from '@/components/task
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
@ -96,7 +97,7 @@ function FeedbackButton({
|
||||
const isBusy = actionBusy === type || Boolean(actionBusy?.endsWith(type));
|
||||
|
||||
return (
|
||||
<Button type="button" variant="outline" className="w-full justify-center" disabled={disabled || Boolean(actionBusy)} onClick={onClick}>
|
||||
<Button type="button" variant="outline" className="h-11 w-full justify-center" disabled={disabled || Boolean(actionBusy)} onClick={onClick}>
|
||||
{isBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : icon}
|
||||
{label}
|
||||
</Button>
|
||||
@ -156,6 +157,7 @@ export function TaskAcceptanceControls({
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
const commentId = React.useId();
|
||||
const [localComment, setLocalComment] = React.useState('');
|
||||
const comment = revision ?? localComment;
|
||||
const setComment = onRevisionChange ?? setLocalComment;
|
||||
@ -224,12 +226,16 @@ export function TaskAcceptanceControls({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={commentId}>{pickAppText(locale, '验收说明', 'Acceptance note')}</Label>
|
||||
<Textarea
|
||||
id={commentId}
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
disabled={Boolean(recordedFeedback) || isFinalized || !isReadyForAcceptance || Boolean(actionBusy)}
|
||||
placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')}
|
||||
/>
|
||||
</div>
|
||||
<div className={`text-xs text-muted-foreground ${containedPreservedLongTextClass}`}>
|
||||
{pickAppText(locale, '验收将记录到当前任务运行:', 'Acceptance will be recorded on run: ')}
|
||||
<span className="font-mono">{runId || '-'}</span>
|
||||
|
||||
@ -43,17 +43,17 @@ export function TaskLiveHeader({ task, activeLabel, durationMs, reviewTargetId }
|
||||
const showReviewLink = Boolean(reviewTargetId && ['awaiting_acceptance', 'needs_revision'].includes(task.status));
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<header className="sticky top-[65px] z-20 min-w-0 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:px-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Button asChild variant="outline" size="sm" className="h-11">
|
||||
<Link href="/tasks">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '返回任务', 'Back to tasks')}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Button asChild variant="ghost" size="sm" className="h-11">
|
||||
<Link href="/">
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '对话', 'Chat')}
|
||||
@ -70,7 +70,7 @@ export function TaskLiveHeader({ task, activeLabel, durationMs, reviewTargetId }
|
||||
)}
|
||||
{activeLabel ? <Badge variant="secondary">{activeLabel}</Badge> : null}
|
||||
{showReviewLink ? (
|
||||
<Button asChild variant="default" size="sm">
|
||||
<Button asChild variant="default" size="sm" className="h-11">
|
||||
<a href={`#${reviewTargetId}`}>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '验收', 'Review')}
|
||||
|
||||
@ -131,7 +131,7 @@ export function TaskSideRail({ task, runs, artifacts, cards }: Props) {
|
||||
const latestAlert = cards.filter(isWarningOrError).sort((a, b) => toTime(b.createdAt) - toTime(a.createdAt))[0] ?? null;
|
||||
|
||||
return (
|
||||
<aside className="space-y-4">
|
||||
<aside className="min-w-0 space-y-4">
|
||||
<Card className="rounded-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{pickAppText(locale, '任务状态', 'Task status')}</CardTitle>
|
||||
@ -230,14 +230,14 @@ export function TaskSideRail({ task, runs, artifacts, cards }: Props) {
|
||||
<div className="mt-1 text-xs text-muted-foreground">{artifact.artifact_type}</div>
|
||||
</div>
|
||||
{href ? (
|
||||
<Button asChild size="sm" variant="outline" className="shrink-0">
|
||||
<Button asChild size="sm" variant="outline" className="h-11 shrink-0">
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{artifact.url ? <ExternalLink className="mr-2 h-3.5 w-3.5" /> : <Download className="mr-2 h-3.5 w-3.5" />}
|
||||
{artifact.url ? pickAppText(locale, '打开', 'Open') : pickAppText(locale, '下载', 'Download')}
|
||||
</a>
|
||||
</Button>
|
||||
) : inlinePayload ? (
|
||||
<Button size="sm" variant="outline" className="shrink-0" onClick={() => downloadInlineArtifact(artifact)}>
|
||||
<Button size="sm" variant="outline" className="h-11 shrink-0" onClick={() => downloadInlineArtifact(artifact)}>
|
||||
<Download className="mr-2 h-3.5 w-3.5" />
|
||||
{pickAppText(locale, '下载', 'Download')}
|
||||
</Button>
|
||||
|
||||
@ -14,13 +14,15 @@ type Props = {
|
||||
isLive: boolean;
|
||||
resultAcceptance?: TaskResultAcceptance;
|
||||
reviewTargetId?: string;
|
||||
showHeader?: boolean;
|
||||
};
|
||||
|
||||
export function TaskTimeline({ cards, isLive, resultAcceptance, reviewTargetId }: Props) {
|
||||
export function TaskTimeline({ cards, isLive, resultAcceptance, reviewTargetId, showHeader = true }: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
{showHeader ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold">{pickAppText(locale, '时间线', 'Timeline')}</h2>
|
||||
{isLive ? (
|
||||
@ -34,6 +36,7 @@ export function TaskTimeline({ cards, isLive, resultAcceptance, reviewTargetId }
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{cards.length === 0 ? (
|
||||
<Card className="rounded-md border-dashed">
|
||||
|
||||
@ -148,7 +148,7 @@ function TaskResultHistory({ card }: { card: TaskTimelineCardView }) {
|
||||
|
||||
return (
|
||||
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-muted/20 px-3 py-2 text-sm">
|
||||
<summary className="flex cursor-pointer select-none items-center justify-between gap-3 font-medium">
|
||||
<summary className="flex min-h-[44px] cursor-pointer select-none items-center justify-between gap-3 font-medium">
|
||||
<span>{pickAppText(locale, '展开历史版本', 'Show previous versions')}</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</summary>
|
||||
@ -182,7 +182,7 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
|
||||
const shouldRenderResultAcceptance = Boolean(card.type === 'result' && resultAcceptance && card.runId === resultAcceptance.runId);
|
||||
|
||||
return (
|
||||
<Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="scroll-mt-28 overflow-hidden rounded-md">
|
||||
<Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="min-w-0 max-w-full scroll-mt-44 overflow-hidden rounded-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
@ -224,7 +224,7 @@ export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Pro
|
||||
|
||||
{card.type === 'result_history' ? <TaskResultHistory card={card} /> : card.details ? (
|
||||
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-muted/20 px-3 py-2 text-xs">
|
||||
<summary className="cursor-pointer select-none font-medium text-muted-foreground">
|
||||
<summary className="flex min-h-[44px] cursor-pointer select-none items-center font-medium text-muted-foreground">
|
||||
{pickAppText(locale, '详情 JSON', 'Details JSON')}
|
||||
</summary>
|
||||
<pre className={`mt-2 max-h-72 overflow-auto text-[11px] leading-5 text-muted-foreground ${containedJsonTextClass}`}>
|
||||
|
||||
@ -41,7 +41,7 @@ export function TaskManagementTabs() {
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-colors',
|
||||
'inline-flex h-11 items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground'
|
||||
|
||||
@ -20,10 +20,10 @@ const buttonVariants = cva(
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
default: 'h-11 px-4 py-2',
|
||||
sm: 'h-11 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
icon: 'h-11 w-11',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@ -38,13 +38,13 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
'fixed left-4 right-4 top-4 z-50 grid max-h-[calc(100vh-2rem)] w-auto max-w-none gap-4 overflow-x-hidden overflow-y-auto rounded-lg border bg-background p-5 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 sm:left-[50%] sm:right-auto sm:top-[50%] sm:w-full sm:max-w-lg sm:translate-x-[-50%] sm:translate-y-[-50%] sm:p-6 sm:data-[state=closed]:zoom-out-95 sm:data-[state=open]:zoom-in-95 sm:data-[state=closed]:slide-out-to-left-1/2 sm:data-[state=closed]:slide-out-to-top-[48%] sm:data-[state=open]:slide-in-from-left-1/2 sm:data-[state=open]:slide-in-from-top-[48%] [&>*]:min-w-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<DialogPrimitive.Close className="absolute right-3 top-3 flex h-11 w-11 items-center justify-center rounded-full opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
||||
@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-transparent bg-input px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/10 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex h-11 w-full rounded-lg border border-transparent bg-input px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/10 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
'flex h-11 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -11,7 +11,7 @@ const Switch = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
'peer inline-flex h-11 w-14 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent px-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -19,7 +19,7 @@ const Switch = React.forwardRef<
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-7 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
|
||||
@ -14,7 +14,7 @@ const TabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||
'inline-flex min-h-11 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -29,7 +29,7 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
'inline-flex h-11 min-h-11 items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
ChatLogsResponse,
|
||||
BackendTask,
|
||||
ChatMessage,
|
||||
ChannelConnectionView,
|
||||
ChannelConfigDetail,
|
||||
ChannelConfigPayload,
|
||||
ChannelConnectorDescriptor,
|
||||
@ -666,6 +667,10 @@ export async function listChannelConnectors(): Promise<ChannelConnectorDescripto
|
||||
return fetchJSON('/api/channel-connectors');
|
||||
}
|
||||
|
||||
export async function listChannelConnections(): Promise<ChannelConnectionView[]> {
|
||||
return fetchJSON('/api/channel-connections');
|
||||
}
|
||||
|
||||
export async function startChannelConnectorSession(
|
||||
payload: ConnectorSessionStartPayload
|
||||
): Promise<ConnectorSessionResponse> {
|
||||
|
||||
60
app-instance/frontend/lib/channel-connector-state.test.ts
Normal file
60
app-instance/frontend/lib/channel-connector-state.test.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { connectorChannelForKind, runtimeBridgeEnabledForPath, visibleConnectorCards } from '@/lib/channel-connector-state';
|
||||
import type { ChannelConnectorDescriptor, ChannelStatus } from '@/types';
|
||||
|
||||
|
||||
function channel(overrides: Partial<ChannelStatus>): ChannelStatus {
|
||||
return {
|
||||
channel_id: 'weixin-main',
|
||||
kind: 'weixin',
|
||||
mode: 'polling',
|
||||
account_id: 'wx-main',
|
||||
display_name: 'Weixin Main',
|
||||
enabled: false,
|
||||
state: 'disabled',
|
||||
capabilities: [],
|
||||
connected_peers: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
describe('connector channel cards', () => {
|
||||
it('does not let a disabled static channel block connector onboarding', () => {
|
||||
expect(connectorChannelForKind('weixin', [channel({})])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('selects a running connector channel', () => {
|
||||
const running = channel({ enabled: true, state: 'running' });
|
||||
|
||||
expect(connectorChannelForKind('weixin', [running])).toEqual(running);
|
||||
});
|
||||
|
||||
it('selects a running terminal channel', () => {
|
||||
const running = channel({
|
||||
channel_id: 'terminal-dev',
|
||||
kind: 'terminal',
|
||||
mode: 'websocket',
|
||||
display_name: 'Terminal Dev',
|
||||
enabled: true,
|
||||
state: 'running',
|
||||
connected_peers: 1,
|
||||
});
|
||||
|
||||
expect(connectorChannelForKind('terminal', [running])).toEqual(running);
|
||||
});
|
||||
|
||||
it('always includes terminal as a local connector card fallback', () => {
|
||||
const connectors: ChannelConnectorDescriptor[] = [{ kind: 'weixin', authType: 'qr' }, { kind: 'feishu', authType: 'plugin' }];
|
||||
|
||||
expect(visibleConnectorCards(connectors).map((connector) => connector.kind)).toEqual(['weixin', 'feishu', 'terminal']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('app runtime bridge', () => {
|
||||
it('stays enabled on settings pages so the global connection status is accurate', () => {
|
||||
expect(runtimeBridgeEnabledForPath('/settings')).toBe(true);
|
||||
});
|
||||
});
|
||||
28
app-instance/frontend/lib/channel-connector-state.ts
Normal file
28
app-instance/frontend/lib/channel-connector-state.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { ChannelConnectorDescriptor, ChannelStatus } from '@/types';
|
||||
|
||||
|
||||
const CONNECTOR_CARD_KINDS = ['weixin', 'feishu', 'terminal'];
|
||||
|
||||
export function connectorChannelForKind(kind: string, channels: ChannelStatus[]): ChannelStatus | undefined {
|
||||
const matches = channels.filter((channel) => channel.kind === kind);
|
||||
return (
|
||||
matches.find((channel) => channel.state === 'running') ||
|
||||
matches.find((channel) => channel.enabled && channel.state !== 'disabled' && channel.state !== 'stopped')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function visibleConnectorCards(connectors: ChannelConnectorDescriptor[]): ChannelConnectorDescriptor[] {
|
||||
const byKind = new Map<string, ChannelConnectorDescriptor>();
|
||||
connectors.forEach((connector) => {
|
||||
if (CONNECTOR_CARD_KINDS.includes(connector.kind) && !byKind.has(connector.kind)) {
|
||||
byKind.set(connector.kind, connector);
|
||||
}
|
||||
});
|
||||
return CONNECTOR_CARD_KINDS.map((kind) => byKind.get(kind) || { kind });
|
||||
}
|
||||
|
||||
|
||||
export function runtimeBridgeEnabledForPath(_pathname: string): boolean {
|
||||
return true;
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
listChannelConnections,
|
||||
getChannelConnectorSession,
|
||||
listChannelConnectors,
|
||||
startChannelConnectorSession,
|
||||
@ -38,6 +39,18 @@ describe('channel connector api', () => {
|
||||
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connectors$/);
|
||||
});
|
||||
|
||||
it('lists existing channel connections', async () => {
|
||||
const fetchMock = vi.fn(() =>
|
||||
mockJsonResponse([{ connection_id: 'conn_1', channel_id: 'weixin-1', kind: 'weixin', status: 'connected' }])
|
||||
);
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const connections = await listChannelConnections();
|
||||
|
||||
expect(connections[0].status).toBe('connected');
|
||||
expect(String(firstFetchCall(fetchMock)[0])).toMatch(/\/api\/channel-connections$/);
|
||||
});
|
||||
|
||||
it('starts a connector session with options', async () => {
|
||||
const fetchMock = vi.fn(() =>
|
||||
mockJsonResponse({
|
||||
|
||||
@ -1,201 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSessionProgressView } from '@/lib/session-progress';
|
||||
import type { ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
|
||||
describe('session progress view builder', () => {
|
||||
it('selects the latest active root run for the current session and builds its run tree', () => {
|
||||
const processRuns: ProcessRun[] = [
|
||||
{
|
||||
run_id: 'old-root',
|
||||
parent_run_id: null,
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main',
|
||||
actor_name: 'Main Agent',
|
||||
title: '旧任务',
|
||||
status: 'done',
|
||||
started_at: '2026-05-22T08:00:00.000Z',
|
||||
finished_at: '2026-05-22T08:05:00.000Z',
|
||||
},
|
||||
{
|
||||
run_id: 'latest-root',
|
||||
parent_run_id: null,
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main',
|
||||
actor_name: 'Main Agent',
|
||||
title: '销售数据分析报告生成',
|
||||
status: 'running',
|
||||
started_at: '2026-05-22T09:00:00.000Z',
|
||||
metadata: {
|
||||
step_index: 3,
|
||||
step_total: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
run_id: 'collect-data',
|
||||
parent_run_id: 'latest-root',
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'collector',
|
||||
actor_name: 'Data Agent',
|
||||
title: '收集销售数据',
|
||||
status: 'done',
|
||||
started_at: '2026-05-22T09:01:00.000Z',
|
||||
finished_at: '2026-05-22T09:03:00.000Z',
|
||||
summary: '已获取 Q1 销售数据',
|
||||
},
|
||||
{
|
||||
run_id: 'clean-data',
|
||||
parent_run_id: 'latest-root',
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'cleaner',
|
||||
actor_name: 'Cleaning Agent',
|
||||
title: '数据清洗与预处理',
|
||||
status: 'running',
|
||||
started_at: '2026-05-22T09:04:00.000Z',
|
||||
},
|
||||
{
|
||||
run_id: 'other-root',
|
||||
parent_run_id: null,
|
||||
session_id: 'web:other',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main',
|
||||
actor_name: 'Main Agent',
|
||||
title: '其他会话任务',
|
||||
status: 'running',
|
||||
started_at: '2026-05-22T10:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-clean',
|
||||
run_id: 'clean-data',
|
||||
parent_run_id: 'latest-root',
|
||||
kind: 'run_progress',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'cleaner',
|
||||
actor_name: 'Cleaning Agent',
|
||||
text: '清洗缺失值、异常值,统一格式',
|
||||
created_at: '2026-05-22T09:05:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const processArtifacts: ProcessArtifact[] = [
|
||||
{
|
||||
artifact_id: 'artifact-json',
|
||||
run_id: 'collect-data',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'collector',
|
||||
actor_name: 'Data Agent',
|
||||
title: '销售数据',
|
||||
artifact_type: 'json',
|
||||
data: { rows: 120 },
|
||||
created_at: '2026-05-22T09:03:30.000Z',
|
||||
},
|
||||
{
|
||||
artifact_id: 'artifact-markdown',
|
||||
run_id: 'clean-data',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'cleaner',
|
||||
actor_name: 'Cleaning Agent',
|
||||
title: '清洗说明',
|
||||
artifact_type: 'markdown',
|
||||
content: '已完成数据标准化。',
|
||||
created_at: '2026-05-22T09:05:30.000Z',
|
||||
},
|
||||
{
|
||||
artifact_id: 'artifact-other-session',
|
||||
run_id: 'other-root',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main',
|
||||
title: '其他会话产物',
|
||||
artifact_type: 'text',
|
||||
content: '不应出现',
|
||||
created_at: '2026-05-22T10:01:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const view = buildSessionProgressView({
|
||||
sessionId: 'web:current',
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
locale: 'zh-CN',
|
||||
});
|
||||
|
||||
expect(view).not.toBeNull();
|
||||
expect(view?.rootRunId).toBe('latest-root');
|
||||
expect(view?.title).toBe('销售数据分析报告生成');
|
||||
expect(view?.progress).toMatchObject({
|
||||
value: 3,
|
||||
max: 5,
|
||||
percent: 60,
|
||||
label: '运行中:3 / 5 步',
|
||||
});
|
||||
expect(view?.steps.map((step) => step.runId)).toEqual(['collect-data', 'clean-data', 'latest-root']);
|
||||
expect(view?.steps.find((step) => step.runId === 'clean-data')?.description).toBe('清洗缺失值、异常值,统一格式');
|
||||
expect(view?.artifactTypeSummaries).toEqual([
|
||||
{ type: 'json', count: 1, label: 'JSON' },
|
||||
{ type: 'markdown', count: 1, label: 'Markdown' },
|
||||
]);
|
||||
expect(view?.artifacts.map((artifact) => artifact.artifactId)).toEqual(['artifact-markdown', 'artifact-json']);
|
||||
});
|
||||
|
||||
it('falls back to completed child run counts when no explicit progress metadata exists', () => {
|
||||
const processRuns: ProcessRun[] = [
|
||||
{
|
||||
run_id: 'root',
|
||||
parent_run_id: null,
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main',
|
||||
actor_name: 'Main Agent',
|
||||
title: '生成总结',
|
||||
status: 'running',
|
||||
started_at: '2026-05-22T09:00:00.000Z',
|
||||
},
|
||||
{
|
||||
run_id: 'done-child',
|
||||
parent_run_id: 'root',
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'writer',
|
||||
actor_name: 'Writer',
|
||||
title: '整理结果',
|
||||
status: 'done',
|
||||
started_at: '2026-05-22T09:01:00.000Z',
|
||||
finished_at: '2026-05-22T09:02:00.000Z',
|
||||
},
|
||||
{
|
||||
run_id: 'running-child',
|
||||
parent_run_id: 'root',
|
||||
session_id: 'web:current',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'reviewer',
|
||||
actor_name: 'Reviewer',
|
||||
title: '复核结果',
|
||||
status: 'running',
|
||||
started_at: '2026-05-22T09:03:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const view = buildSessionProgressView({
|
||||
sessionId: 'web:current',
|
||||
processRuns,
|
||||
processEvents: [],
|
||||
processArtifacts: [],
|
||||
locale: 'zh-CN',
|
||||
});
|
||||
|
||||
expect(view?.progress).toMatchObject({
|
||||
value: 1,
|
||||
max: 2,
|
||||
percent: 50,
|
||||
label: '已完成 1 / 2 步',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,392 +0,0 @@
|
||||
import type { ProcessArtifact, ProcessEvent, ProcessRun, ProcessRunStatus } from '@/types';
|
||||
import { getCurrentAppLocale, pickAppText, type AppLocale } from '@/lib/i18n/core';
|
||||
|
||||
const TERMINAL_STATUSES = new Set<ProcessRunStatus>(['done', 'error', 'cancelled']);
|
||||
const ACTIVE_STATUSES = new Set<ProcessRunStatus>(['queued', 'running', 'waiting']);
|
||||
const ARTIFACT_TYPE_ORDER: ProcessArtifact['artifact_type'][] = [
|
||||
'text',
|
||||
'json',
|
||||
'file',
|
||||
'image',
|
||||
'link',
|
||||
'markdown',
|
||||
];
|
||||
|
||||
export interface SessionProgressValueView {
|
||||
label: string;
|
||||
value: number | null;
|
||||
max: number | null;
|
||||
percent: number | null;
|
||||
}
|
||||
|
||||
export interface SessionProgressStepView {
|
||||
runId: string;
|
||||
title: string;
|
||||
actorName: string;
|
||||
status: ProcessRunStatus;
|
||||
description: string | null;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
finishedAt: string | null;
|
||||
artifactCount: number;
|
||||
isRoot: boolean;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface SessionProgressArtifactView {
|
||||
artifactId: string;
|
||||
runId: string;
|
||||
title: string;
|
||||
type: ProcessArtifact['artifact_type'];
|
||||
typeLabel: string;
|
||||
actorName: string;
|
||||
preview: string;
|
||||
createdAt: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface SessionProgressArtifactTypeSummary {
|
||||
type: ProcessArtifact['artifact_type'];
|
||||
count: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SessionProgressView {
|
||||
rootRunId: string;
|
||||
title: string;
|
||||
status: ProcessRunStatus;
|
||||
summary: string | null;
|
||||
updatedAt: string;
|
||||
progress: SessionProgressValueView;
|
||||
steps: SessionProgressStepView[];
|
||||
artifacts: SessionProgressArtifactView[];
|
||||
artifactTypeSummaries: SessionProgressArtifactTypeSummary[];
|
||||
}
|
||||
|
||||
export type BuildSessionProgressInput = {
|
||||
sessionId: string;
|
||||
processRuns: ProcessRun[];
|
||||
processEvents: ProcessEvent[];
|
||||
processArtifacts: ProcessArtifact[];
|
||||
locale?: AppLocale;
|
||||
};
|
||||
|
||||
function toTime(value?: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function latestTimestamp(values: Array<string | null | undefined>): string | null {
|
||||
let selected: string | null = null;
|
||||
let selectedTime = -1;
|
||||
for (const value of values) {
|
||||
const time = toTime(value);
|
||||
if (time === null || time <= selectedTime) continue;
|
||||
selected = value ?? null;
|
||||
selectedTime = time;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function compareIsoDesc(a?: string | null, b?: string | null): number {
|
||||
return (toTime(b) ?? 0) - (toTime(a) ?? 0);
|
||||
}
|
||||
|
||||
function firstNumber(metadata: Record<string, unknown> | undefined, keys: string[]): number | null {
|
||||
for (const key of keys) {
|
||||
const value = metadata?.[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildChildrenMap(processRuns: ProcessRun[]): Map<string, ProcessRun[]> {
|
||||
const map = new Map<string, ProcessRun[]>();
|
||||
for (const run of processRuns) {
|
||||
if (!run.parent_run_id) continue;
|
||||
const children = map.get(run.parent_run_id);
|
||||
if (children) {
|
||||
children.push(run);
|
||||
} else {
|
||||
map.set(run.parent_run_id, [run]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function collectRunTree(rootRun: ProcessRun, childrenMap: Map<string, ProcessRun[]>): ProcessRun[] {
|
||||
const collected: ProcessRun[] = [];
|
||||
const stack = [rootRun];
|
||||
const seen = new Set<string>();
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current || seen.has(current.run_id)) continue;
|
||||
seen.add(current.run_id);
|
||||
collected.push(current);
|
||||
const children = childrenMap.get(current.run_id) ?? [];
|
||||
for (let index = children.length - 1; index >= 0; index -= 1) {
|
||||
stack.push(children[index]);
|
||||
}
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
function groupByRunId<T extends { run_id: string }>(items: T[]): Map<string, T[]> {
|
||||
const map = new Map<string, T[]>();
|
||||
for (const item of items) {
|
||||
const existing = map.get(item.run_id);
|
||||
if (existing) {
|
||||
existing.push(item);
|
||||
} else {
|
||||
map.set(item.run_id, [item]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function getRunUpdatedAt(
|
||||
run: ProcessRun,
|
||||
eventsByRun: Map<string, ProcessEvent[]>,
|
||||
artifactsByRun: Map<string, ProcessArtifact[]>,
|
||||
): string {
|
||||
return (
|
||||
latestTimestamp([
|
||||
run.finished_at,
|
||||
run.started_at,
|
||||
...(eventsByRun.get(run.run_id) ?? []).map((event) => event.created_at),
|
||||
...(artifactsByRun.get(run.run_id) ?? []).map((artifact) => artifact.created_at),
|
||||
]) ?? run.started_at
|
||||
);
|
||||
}
|
||||
|
||||
function getTreeUpdatedAt(
|
||||
runs: ProcessRun[],
|
||||
eventsByRun: Map<string, ProcessEvent[]>,
|
||||
artifactsByRun: Map<string, ProcessArtifact[]>,
|
||||
): string {
|
||||
return latestTimestamp(runs.map((run) => getRunUpdatedAt(run, eventsByRun, artifactsByRun))) ?? runs[0]?.started_at ?? '';
|
||||
}
|
||||
|
||||
function latestEventText(events: ProcessEvent[]): string | null {
|
||||
const event = [...events]
|
||||
.filter((item) => item.text?.trim())
|
||||
.sort((a, b) => compareIsoDesc(a.created_at, b.created_at))[0];
|
||||
return event?.text?.trim() || null;
|
||||
}
|
||||
|
||||
function percent(value: number, max: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round((value / max) * 100)));
|
||||
}
|
||||
|
||||
function explicitProgress(
|
||||
rootRun: ProcessRun,
|
||||
treeEvents: ProcessEvent[],
|
||||
locale: AppLocale,
|
||||
): SessionProgressValueView | null {
|
||||
const metadataSources = [
|
||||
rootRun.metadata,
|
||||
...[...treeEvents]
|
||||
.sort((a, b) => compareIsoDesc(a.created_at, b.created_at))
|
||||
.map((event) => event.metadata),
|
||||
];
|
||||
|
||||
for (const metadata of metadataSources) {
|
||||
const stepValue = firstNumber(metadata, ['step_index']);
|
||||
const stepMax = firstNumber(metadata, ['step_total']);
|
||||
if (stepValue !== null && stepMax !== null && stepMax > 0) {
|
||||
const safeValue = Math.min(stepValue, stepMax);
|
||||
return {
|
||||
label: pickAppText(locale, `运行中:${safeValue} / ${stepMax} 步`, `Running: ${safeValue} / ${stepMax} steps`),
|
||||
value: safeValue,
|
||||
max: stepMax,
|
||||
percent: percent(safeValue, stepMax),
|
||||
};
|
||||
}
|
||||
|
||||
const stageValue = firstNumber(metadata, ['stage_index', 'phase_index']);
|
||||
const stageMax = firstNumber(metadata, ['stage_total', 'phase_total']);
|
||||
if (stageValue !== null && stageMax !== null && stageMax > 0) {
|
||||
const safeValue = Math.min(stageValue, stageMax);
|
||||
return {
|
||||
label: pickAppText(locale, `运行中:${safeValue} / ${stageMax} 阶段`, `Running: ${safeValue} / ${stageMax} stages`),
|
||||
value: safeValue,
|
||||
max: stageMax,
|
||||
percent: percent(safeValue, stageMax),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function fallbackProgress(taskRuns: ProcessRun[], locale: AppLocale): SessionProgressValueView {
|
||||
const childRuns = taskRuns.filter((run) => run.parent_run_id);
|
||||
const runsForProgress = childRuns.length > 0 ? childRuns : taskRuns;
|
||||
const doneRuns = runsForProgress.filter((run) => run.status === 'done').length;
|
||||
const totalRuns = runsForProgress.length;
|
||||
|
||||
if (totalRuns > 0) {
|
||||
return {
|
||||
label: pickAppText(locale, `已完成 ${doneRuns} / ${totalRuns} 步`, `Completed ${doneRuns} / ${totalRuns} steps`),
|
||||
value: doneRuns,
|
||||
max: totalRuns,
|
||||
percent: percent(doneRuns, totalRuns),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: pickAppText(locale, '等待任务数据', 'Waiting for task data'),
|
||||
value: null,
|
||||
max: null,
|
||||
percent: null,
|
||||
};
|
||||
}
|
||||
|
||||
function artifactTypeLabel(type: ProcessArtifact['artifact_type'], locale: AppLocale): string {
|
||||
if (type === 'text') return pickAppText(locale, '文本', 'Text');
|
||||
if (type === 'json') return 'JSON';
|
||||
if (type === 'file') return pickAppText(locale, '文件', 'File');
|
||||
if (type === 'image') return pickAppText(locale, '图片', 'Image');
|
||||
if (type === 'link') return pickAppText(locale, '链接', 'Link');
|
||||
return 'Markdown';
|
||||
}
|
||||
|
||||
function artifactPreview(artifact: ProcessArtifact, locale: AppLocale): string {
|
||||
if (artifact.content?.trim()) {
|
||||
return artifact.content.trim().replace(/\s+/g, ' ').slice(0, 120);
|
||||
}
|
||||
if (artifact.url?.trim()) return artifact.url.trim();
|
||||
if (artifact.data !== undefined) {
|
||||
return JSON.stringify(artifact.data).slice(0, 120);
|
||||
}
|
||||
return pickAppText(locale, '暂无预览', 'No preview');
|
||||
}
|
||||
|
||||
function buildArtifactSummaries(
|
||||
artifacts: ProcessArtifact[],
|
||||
locale: AppLocale,
|
||||
): SessionProgressArtifactTypeSummary[] {
|
||||
const counts = new Map<ProcessArtifact['artifact_type'], number>();
|
||||
for (const artifact of artifacts) {
|
||||
counts.set(artifact.artifact_type, (counts.get(artifact.artifact_type) ?? 0) + 1);
|
||||
}
|
||||
return ARTIFACT_TYPE_ORDER
|
||||
.filter((type) => counts.has(type))
|
||||
.map((type) => ({
|
||||
type,
|
||||
count: counts.get(type) ?? 0,
|
||||
label: artifactTypeLabel(type, locale),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildArtifactViews(
|
||||
artifacts: ProcessArtifact[],
|
||||
locale: AppLocale,
|
||||
): SessionProgressArtifactView[] {
|
||||
return [...artifacts]
|
||||
.sort((a, b) => compareIsoDesc(a.created_at, b.created_at))
|
||||
.map((artifact) => ({
|
||||
artifactId: artifact.artifact_id,
|
||||
runId: artifact.run_id,
|
||||
title: artifact.title,
|
||||
type: artifact.artifact_type,
|
||||
typeLabel: artifactTypeLabel(artifact.artifact_type, locale),
|
||||
actorName: artifact.actor_name || artifact.actor_id,
|
||||
preview: artifactPreview(artifact, locale),
|
||||
createdAt: artifact.created_at,
|
||||
url: artifact.url,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildSteps(
|
||||
rootRun: ProcessRun,
|
||||
taskRuns: ProcessRun[],
|
||||
eventsByRun: Map<string, ProcessEvent[]>,
|
||||
artifactsByRun: Map<string, ProcessArtifact[]>,
|
||||
): SessionProgressStepView[] {
|
||||
return [...taskRuns]
|
||||
.sort((a, b) => {
|
||||
if (a.run_id === rootRun.run_id) return 1;
|
||||
if (b.run_id === rootRun.run_id) return -1;
|
||||
return (toTime(a.started_at) ?? 0) - (toTime(b.started_at) ?? 0);
|
||||
})
|
||||
.map((run) => {
|
||||
const runEvents = eventsByRun.get(run.run_id) ?? [];
|
||||
const runArtifacts = artifactsByRun.get(run.run_id) ?? [];
|
||||
return {
|
||||
runId: run.run_id,
|
||||
title: run.title,
|
||||
actorName: run.actor_name,
|
||||
status: run.status,
|
||||
description: latestEventText(runEvents) || run.summary?.trim() || null,
|
||||
startedAt: run.started_at,
|
||||
updatedAt: getRunUpdatedAt(run, eventsByRun, artifactsByRun),
|
||||
finishedAt: run.finished_at ?? null,
|
||||
artifactCount: runArtifacts.length,
|
||||
isRoot: run.run_id === rootRun.run_id,
|
||||
isCurrent: !TERMINAL_STATUSES.has(run.status),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildSessionProgressView({
|
||||
sessionId,
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
locale = getCurrentAppLocale(),
|
||||
}: BuildSessionProgressInput): SessionProgressView | null {
|
||||
const sessionRuns = processRuns.filter((run) => run.session_id === sessionId);
|
||||
const rootRuns = sessionRuns.filter((run) => !run.parent_run_id);
|
||||
if (rootRuns.length === 0) return null;
|
||||
|
||||
const allChildrenMap = buildChildrenMap(processRuns);
|
||||
const runTreeCache = new Map<string, ProcessRun[]>();
|
||||
const treeForRoot = (root: ProcessRun) => {
|
||||
const cached = runTreeCache.get(root.run_id);
|
||||
if (cached) return cached;
|
||||
const tree = collectRunTree(root, allChildrenMap).filter(
|
||||
(run) => run.session_id === sessionId || run.run_id === root.run_id
|
||||
);
|
||||
runTreeCache.set(root.run_id, tree);
|
||||
return tree;
|
||||
};
|
||||
|
||||
const allEventsByRun = groupByRunId(processEvents);
|
||||
const allArtifactsByRun = groupByRunId(processArtifacts);
|
||||
const selectedRoot = [...rootRuns].sort((a, b) => {
|
||||
const aActive = ACTIVE_STATUSES.has(a.status);
|
||||
const bActive = ACTIVE_STATUSES.has(b.status);
|
||||
if (aActive !== bActive) return aActive ? -1 : 1;
|
||||
return compareIsoDesc(
|
||||
getTreeUpdatedAt(treeForRoot(a), allEventsByRun, allArtifactsByRun),
|
||||
getTreeUpdatedAt(treeForRoot(b), allEventsByRun, allArtifactsByRun)
|
||||
);
|
||||
})[0];
|
||||
|
||||
if (!selectedRoot) return null;
|
||||
|
||||
const taskRuns = treeForRoot(selectedRoot);
|
||||
const taskRunIds = new Set(taskRuns.map((run) => run.run_id));
|
||||
const taskEvents = processEvents.filter((event) => taskRunIds.has(event.run_id));
|
||||
const taskArtifacts = processArtifacts.filter((artifact) => taskRunIds.has(artifact.run_id));
|
||||
const eventsByRun = groupByRunId(taskEvents);
|
||||
const artifactsByRun = groupByRunId(taskArtifacts);
|
||||
const updatedAt = getTreeUpdatedAt(taskRuns, eventsByRun, artifactsByRun);
|
||||
const progress = explicitProgress(selectedRoot, taskEvents, locale) ?? fallbackProgress(taskRuns, locale);
|
||||
|
||||
return {
|
||||
rootRunId: selectedRoot.run_id,
|
||||
title: selectedRoot.title,
|
||||
status: selectedRoot.status,
|
||||
summary: selectedRoot.summary?.trim() || latestEventText(eventsByRun.get(selectedRoot.run_id) ?? []) || null,
|
||||
updatedAt,
|
||||
progress,
|
||||
steps: buildSteps(selectedRoot, taskRuns, eventsByRun, artifactsByRun),
|
||||
artifacts: buildArtifactViews(taskArtifacts, locale),
|
||||
artifactTypeSummaries: buildArtifactSummaries(taskArtifacts, locale),
|
||||
};
|
||||
}
|
||||
160
app-instance/frontend/lib/task-process.test.ts
Normal file
160
app-instance/frontend/lib/task-process.test.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { selectTaskProcess } from '@/lib/task-process';
|
||||
import type { BackendTask, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
|
||||
function task(): BackendTask {
|
||||
return {
|
||||
task_id: 'task-1',
|
||||
session_id: 'web:default',
|
||||
description: 'Build report',
|
||||
goal: 'Build report',
|
||||
constraints: [],
|
||||
priority: 0,
|
||||
status: 'running',
|
||||
creator: 'user',
|
||||
created_at: '2026-06-04T00:00:00.000Z',
|
||||
updated_at: '2026-06-04T00:01:00.000Z',
|
||||
run_ids: ['main-run'],
|
||||
skill_names: [],
|
||||
feedback: [],
|
||||
metadata: {},
|
||||
process_runs: [
|
||||
{
|
||||
run_id: 'main-run',
|
||||
session_id: 'web:default',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main',
|
||||
actor_name: 'Main Agent',
|
||||
title: 'Persisted main run',
|
||||
status: 'waiting',
|
||||
started_at: '2026-06-04T00:00:10.000Z',
|
||||
metadata: { task_id: 'task-1' },
|
||||
},
|
||||
],
|
||||
process_events: [
|
||||
{
|
||||
event_id: 'persisted-event',
|
||||
run_id: 'main-run',
|
||||
kind: 'task_planned',
|
||||
actor_type: 'system',
|
||||
actor_id: 'planner',
|
||||
actor_name: 'Planner',
|
||||
text: 'Persisted plan',
|
||||
created_at: '2026-06-04T00:00:20.000Z',
|
||||
metadata: { task_id: 'task-1' },
|
||||
},
|
||||
],
|
||||
process_artifacts: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('selectTaskProcess', () => {
|
||||
it('merges persisted and live task process data while excluding other tasks', () => {
|
||||
const liveRuns: ProcessRun[] = [
|
||||
{
|
||||
run_id: 'main-run',
|
||||
session_id: 'web:default',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'main',
|
||||
actor_name: 'Main Agent',
|
||||
title: 'Live main run',
|
||||
status: 'running',
|
||||
started_at: '2026-06-04T00:00:10.000Z',
|
||||
metadata: { task_id: 'task-1' },
|
||||
},
|
||||
{
|
||||
run_id: 'child-run',
|
||||
parent_run_id: 'main-run',
|
||||
session_id: 'subagent:child',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'child',
|
||||
actor_name: 'Child Agent',
|
||||
title: 'Child work',
|
||||
status: 'done',
|
||||
started_at: '2026-06-04T00:00:30.000Z',
|
||||
},
|
||||
{
|
||||
run_id: 'other-run',
|
||||
session_id: 'web:default',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'other',
|
||||
actor_name: 'Other Agent',
|
||||
title: 'Other task',
|
||||
status: 'running',
|
||||
started_at: '2026-06-04T00:00:40.000Z',
|
||||
metadata: { task_id: 'task-2' },
|
||||
},
|
||||
];
|
||||
const liveEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'child-event',
|
||||
run_id: 'child-run',
|
||||
parent_run_id: 'main-run',
|
||||
kind: 'run_progress',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'child',
|
||||
actor_name: 'Child Agent',
|
||||
text: 'Child finished',
|
||||
created_at: '2026-06-04T00:00:50.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'other-event',
|
||||
run_id: 'other-run',
|
||||
kind: 'run_progress',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'other',
|
||||
actor_name: 'Other Agent',
|
||||
text: 'Other task progress',
|
||||
created_at: '2026-06-04T00:00:55.000Z',
|
||||
metadata: { task_id: 'task-2' },
|
||||
},
|
||||
];
|
||||
const liveArtifacts: ProcessArtifact[] = [
|
||||
{
|
||||
artifact_id: 'child-artifact',
|
||||
run_id: 'child-run',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'child',
|
||||
actor_name: 'Child Agent',
|
||||
title: 'Child result',
|
||||
artifact_type: 'text',
|
||||
created_at: '2026-06-04T00:01:00.000Z',
|
||||
},
|
||||
{
|
||||
artifact_id: 'other-artifact',
|
||||
run_id: 'other-run',
|
||||
actor_type: 'agent',
|
||||
actor_id: 'other',
|
||||
title: 'Other result',
|
||||
artifact_type: 'text',
|
||||
created_at: '2026-06-04T00:01:05.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const selected = selectTaskProcess({
|
||||
task: task(),
|
||||
liveRuns,
|
||||
liveEvents,
|
||||
liveArtifacts,
|
||||
});
|
||||
|
||||
expect(selected.runs.map((run) => run.run_id)).toEqual(['main-run', 'child-run']);
|
||||
expect(selected.runs[0].title).toBe('Live main run');
|
||||
expect(selected.events.map((event) => event.event_id)).toEqual(['persisted-event', 'child-event']);
|
||||
expect(selected.artifacts.map((artifact) => artifact.artifact_id)).toEqual(['child-artifact']);
|
||||
});
|
||||
|
||||
it('returns persisted task process data when no live data is available', () => {
|
||||
const selected = selectTaskProcess({
|
||||
task: task(),
|
||||
liveRuns: [],
|
||||
liveEvents: [],
|
||||
liveArtifacts: [],
|
||||
});
|
||||
|
||||
expect(selected.runs.map((run) => run.run_id)).toEqual(['main-run']);
|
||||
expect(selected.events.map((event) => event.event_id)).toEqual(['persisted-event']);
|
||||
expect(selected.artifacts).toEqual([]);
|
||||
});
|
||||
});
|
||||
75
app-instance/frontend/lib/task-process.ts
Normal file
75
app-instance/frontend/lib/task-process.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import type { BackendTask, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||
|
||||
export type TaskProcessSelection = {
|
||||
runs: ProcessRun[];
|
||||
events: ProcessEvent[];
|
||||
artifacts: ProcessArtifact[];
|
||||
};
|
||||
|
||||
export type SelectTaskProcessInput = {
|
||||
task: BackendTask;
|
||||
liveRuns?: ProcessRun[];
|
||||
liveEvents?: ProcessEvent[];
|
||||
liveArtifacts?: ProcessArtifact[];
|
||||
};
|
||||
|
||||
function mergeById<T>(
|
||||
persisted: T[],
|
||||
live: T[],
|
||||
idFor: (item: T) => string,
|
||||
): T[] {
|
||||
const merged = new Map<string, T>();
|
||||
for (const item of persisted) merged.set(idFor(item), item);
|
||||
for (const item of live) merged.set(idFor(item), item);
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
function runBelongsToTask(run: ProcessRun, taskId: string, runIds: Set<string>): boolean {
|
||||
return runIds.has(run.run_id) || run.metadata?.task_id === taskId;
|
||||
}
|
||||
|
||||
function expandTaskRunIds(runs: ProcessRun[], taskId: string, seedRunIds: Set<string>): Set<string> {
|
||||
const selected = new Set(seedRunIds);
|
||||
for (const run of runs) {
|
||||
if (run.metadata?.task_id === taskId) selected.add(run.run_id);
|
||||
}
|
||||
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const run of runs) {
|
||||
if (!run.parent_run_id || !selected.has(run.parent_run_id) || selected.has(run.run_id)) continue;
|
||||
selected.add(run.run_id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
export function selectTaskProcess({
|
||||
task,
|
||||
liveRuns = [],
|
||||
liveEvents = [],
|
||||
liveArtifacts = [],
|
||||
}: SelectTaskProcessInput): TaskProcessSelection {
|
||||
const persistedRuns = task.process_runs ?? [];
|
||||
const persistedEvents = task.process_events ?? [];
|
||||
const persistedArtifacts = task.process_artifacts ?? [];
|
||||
const allRuns = mergeById(persistedRuns, liveRuns, (run) => run.run_id);
|
||||
const seedRunIds = new Set([
|
||||
...task.run_ids.filter(Boolean),
|
||||
...persistedRuns.map((run) => run.run_id),
|
||||
]);
|
||||
const taskRunIds = expandTaskRunIds(allRuns, task.task_id, seedRunIds);
|
||||
const runs = allRuns.filter((run) => runBelongsToTask(run, task.task_id, taskRunIds));
|
||||
|
||||
return {
|
||||
runs,
|
||||
events: mergeById(persistedEvents, liveEvents, (event) => event.event_id).filter(
|
||||
(event) => taskRunIds.has(event.run_id) || event.metadata?.task_id === task.task_id
|
||||
),
|
||||
artifacts: mergeById(persistedArtifacts, liveArtifacts, (artifact) => artifact.artifact_id).filter(
|
||||
(artifact) => taskRunIds.has(artifact.run_id) || artifact.metadata?.task_id === task.task_id
|
||||
),
|
||||
};
|
||||
}
|
||||
52
app-instance/frontend/lib/task-timeline-view.test.ts
Normal file
52
app-instance/frontend/lib/task-timeline-view.test.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTaskTimelineView } from '@/lib/task-timeline-view';
|
||||
import type { BackendTask, ProcessEvent } from '@/types';
|
||||
|
||||
function task(): BackendTask {
|
||||
return {
|
||||
task_id: 'task-1',
|
||||
session_id: 'web:default',
|
||||
description: 'Build report',
|
||||
goal: 'Build report',
|
||||
constraints: [],
|
||||
priority: 0,
|
||||
status: 'running',
|
||||
creator: 'user',
|
||||
created_at: '2026-06-04T00:00:00.000Z',
|
||||
updated_at: '2026-06-04T00:01:00.000Z',
|
||||
run_ids: ['main-run'],
|
||||
skill_names: [],
|
||||
feedback: [],
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildTaskTimelineView', () => {
|
||||
it('builds canonical task timeline cards from matching live process data', () => {
|
||||
const liveEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'plan',
|
||||
run_id: 'main-run',
|
||||
kind: 'task_planned',
|
||||
actor_type: 'system',
|
||||
actor_id: 'planner',
|
||||
actor_name: 'Planner',
|
||||
text: 'Plan created',
|
||||
created_at: '2026-06-04T00:00:10.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const view = buildTaskTimelineView({
|
||||
task: task(),
|
||||
liveEvents,
|
||||
});
|
||||
|
||||
expect(view?.cards.map((card) => card.type)).toEqual(['task_created', 'plan']);
|
||||
expect(view?.process.events.map((event) => event.event_id)).toEqual(['plan']);
|
||||
});
|
||||
|
||||
it('returns null when there is no active task to display', () => {
|
||||
expect(buildTaskTimelineView({ task: null })).toBeNull();
|
||||
});
|
||||
});
|
||||
37
app-instance/frontend/lib/task-timeline-view.ts
Normal file
37
app-instance/frontend/lib/task-timeline-view.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { selectTaskProcess, type SelectTaskProcessInput, type TaskProcessSelection } from '@/lib/task-process';
|
||||
import { buildTaskTimelineCards } from '@/lib/task-timeline';
|
||||
import type { BackendTask, TaskTimelineCard } from '@/types';
|
||||
|
||||
export type BuildTaskTimelineViewInput = Omit<SelectTaskProcessInput, 'task'> & {
|
||||
task: BackendTask | null;
|
||||
};
|
||||
|
||||
export type TaskTimelineView = {
|
||||
process: TaskProcessSelection;
|
||||
cards: TaskTimelineCard[];
|
||||
};
|
||||
|
||||
export function buildTaskTimelineView({
|
||||
task,
|
||||
liveRuns,
|
||||
liveEvents,
|
||||
liveArtifacts,
|
||||
}: BuildTaskTimelineViewInput): TaskTimelineView | null {
|
||||
if (!task) return null;
|
||||
|
||||
const process = selectTaskProcess({
|
||||
task,
|
||||
liveRuns,
|
||||
liveEvents,
|
||||
liveArtifacts,
|
||||
});
|
||||
return {
|
||||
process,
|
||||
cards: buildTaskTimelineCards({
|
||||
task,
|
||||
processRuns: process.runs,
|
||||
processEvents: process.events,
|
||||
processArtifacts: process.artifacts,
|
||||
}),
|
||||
};
|
||||
}
|
||||
9
app-instance/frontend/test-results/.last-run.json
Normal file
9
app-instance/frontend/test-results/.last-run.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"f5227d990c583bf1cb5e-1c04b5ceddc2ff622275",
|
||||
"f5227d990c583bf1cb5e-960e0b45f6e36c90c9ae",
|
||||
"f5227d990c583bf1cb5e-205bf022968074780a04",
|
||||
"f5227d990c583bf1cb5e-ebfd66136ede563d8670"
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,281 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: market-settings-qa.spec.js >> marketplace and settings QA >> marketplace search detail file install flow works
|
||||
- Location: ../../../../../../tmp/beaver-market-settings-qa/market-settings-qa.spec.js:188:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: expect(locator).toBeVisible() failed
|
||||
|
||||
Locator: getByText(/src\/very-long-ui-token/)
|
||||
Expected: visible
|
||||
Error: strict mode violation: getByText(/src\/very-long-ui-token/) resolved to 3 elements:
|
||||
1) <span class="min-w-0 break-all font-mono text-xs">src/very-long-ui-token-0123456789abcdefghijklmnop…</span> aka getByRole('button', { name: 'src/very-long-ui-token-' })
|
||||
2) <div class="break-all font-mono text-sm font-medium">src/very-long-ui-token-0123456789abcdefghijklmnop…</div> aka getByText('src/very-long-ui-token-').nth(1)
|
||||
3) <h1>src/very-long-ui-token-0123456789abcdefghijklmnop…</h1> aka getByRole('heading', { name: 'src/very-long-ui-token-' })
|
||||
|
||||
Call log:
|
||||
- Expect "toBeVisible" with timeout 8000ms
|
||||
- waiting for getByText(/src\/very-long-ui-token/)
|
||||
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e1]:
|
||||
- generic [ref=e2]:
|
||||
- banner [ref=e3]:
|
||||
- generic [ref=e5]:
|
||||
- generic [ref=e6]:
|
||||
- button "Open navigation" [ref=e7] [cursor=pointer]:
|
||||
- img [ref=e8]
|
||||
- link "Beaver" [ref=e9] [cursor=pointer]:
|
||||
- /url: /
|
||||
- generic [ref=e10]: Beaver
|
||||
- generic [ref=e12]:
|
||||
- generic [ref=e13]:
|
||||
- img [ref=e14]
|
||||
- button "ZH" [ref=e18] [cursor=pointer]
|
||||
- button "EN" [ref=e19] [cursor=pointer]
|
||||
- button "Open account menu" [ref=e20] [cursor=pointer]:
|
||||
- generic [ref=e22]: U
|
||||
- main [ref=e23]:
|
||||
- generic [ref=e24]:
|
||||
- generic [ref=e26]:
|
||||
- generic [ref=e27]:
|
||||
- img [ref=e28]
|
||||
- textbox "Search skills..." [ref=e31]: qa
|
||||
- button "Search" [ref=e32] [cursor=pointer]
|
||||
- generic [ref=e33]:
|
||||
- button "Back to search" [ref=e34] [cursor=pointer]:
|
||||
- img [ref=e35]
|
||||
- text: Back to search
|
||||
- generic [ref=e37]:
|
||||
- generic [ref=e39]:
|
||||
- generic [ref=e40]:
|
||||
- generic [ref=e41]:
|
||||
- generic [ref=e42]: "@team-very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789"
|
||||
- generic [ref=e43]: "Downloads: 12345"
|
||||
- generic [ref=e44]: "Stars: 7"
|
||||
- generic [ref=e45]: v1.0.0-alpha-long-version-name-0123456789
|
||||
- heading "SkillHub very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789" [level=2] [ref=e46]
|
||||
- paragraph [ref=e47]: 这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。 https://example.com/very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789
|
||||
- button "Install" [ref=e48] [cursor=pointer]:
|
||||
- img [ref=e49]
|
||||
- text: Install
|
||||
- generic [ref=e52]:
|
||||
- tablist [ref=e53]:
|
||||
- tab "Overview" [ref=e54] [cursor=pointer]
|
||||
- tab "Files" [selected] [ref=e55] [cursor=pointer]
|
||||
- tab "Versions" [ref=e56] [cursor=pointer]
|
||||
- tabpanel "Files" [ref=e57]:
|
||||
- generic [ref=e58]:
|
||||
- generic [ref=e60]:
|
||||
- button "SKILL.md 2.0 KB" [ref=e61] [cursor=pointer]:
|
||||
- generic [ref=e62]: SKILL.md
|
||||
- generic [ref=e63]: 2.0 KB
|
||||
- button "src/very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789/implementation.ts 4.0 KB" [active] [ref=e64] [cursor=pointer]:
|
||||
- generic [ref=e65]: src/very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789/implementation.ts
|
||||
- generic [ref=e66]: 4.0 KB
|
||||
- generic [ref=e68]:
|
||||
- generic [ref=e69]:
|
||||
- generic [ref=e70]: src/very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789/implementation.ts
|
||||
- generic [ref=e71]: "Size: 2.0 KB"
|
||||
- generic [ref=e72]:
|
||||
- heading "src/very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789/implementation.ts" [level=1] [ref=e73]
|
||||
- paragraph [ref=e74]: 这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。这是用于市场页和配置页响应式检测的超长文本。
|
||||
- paragraph [ref=e75]:
|
||||
- code [ref=e76]: "`very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789`"
|
||||
- alert [ref=e77]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
99 | });
|
||||
100 | await page.route('**/api/marketplaces/skills/search**', async (route) => {
|
||||
101 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
102 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [skillItem()], total: 1, page: 0, size: 12 }) });
|
||||
103 | });
|
||||
104 | await page.route('**/api/marketplaces/skills/*/*/versions/*/file**', async (route) => {
|
||||
105 | const url = new URL(route.request().url());
|
||||
106 | const filePath = url.searchParams.get('path') || 'SKILL.md';
|
||||
107 | callLog.push({ method: route.request().method(), url: route.request().url(), filePath });
|
||||
108 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ filePath, fileSize: 2000, contentType: 'text/markdown', isBinary: false, content: `# ${filePath}\n\n${LONG_TEXT}\n\n\`${LONG}${LONG}\`` }) });
|
||||
109 | });
|
||||
110 | await page.route('**/api/marketplaces/skills/*/*/versions/*', async (route) => {
|
||||
111 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
112 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ detail: { version: '1.0.0-alpha-long-version-name-0123456789', status: 'published', parsedMetadataJson: JSON.stringify({ body: `# Skill\n\n${LONG_TEXT}` }) }, files: [{ filePath: 'SKILL.md', fileSize: 2048, contentType: 'text/markdown' }, { filePath: `src/${LONG}/implementation.ts`, fileSize: 4096, contentType: 'text/plain' }] }) });
|
||||
113 | });
|
||||
114 | await page.route('**/api/marketplaces/skills/*/*/versions', async (route) => {
|
||||
115 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
116 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [{ version: '1.0.0-alpha-long-version-name-0123456789', status: 'published', createdAt: '2026-06-04T08:00:00Z', changeReason: LONG_TEXT }], total: 1, page: 0, size: 20 }) });
|
||||
117 | });
|
||||
118 | await page.route('**/api/marketplaces/skills/*/*/install', async (route) => {
|
||||
119 | callLog.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON?.() });
|
||||
120 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, skill_name: `skill-${LONG}`, version: '1.0.0-alpha-long-version-name-0123456789', source: 'skillhub', namespace: `team-${LONG}` }) });
|
||||
121 | });
|
||||
122 | await page.route('**/api/marketplaces/skills/*/*', async (route) => {
|
||||
123 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
124 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(skillItem()) });
|
||||
125 | });
|
||||
126 | await page.route('**/api/status', async (route) => {
|
||||
127 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
128 | if (statusError) {
|
||||
129 | await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ detail: 'QA status error' }) });
|
||||
130 | return;
|
||||
131 | }
|
||||
132 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(statusPayload()) });
|
||||
133 | });
|
||||
134 | await page.route('**/api/providers/*/config', async (route) => {
|
||||
135 | callLog.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON?.() });
|
||||
136 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
|
||||
137 | });
|
||||
138 | await page.route('**/api/agent/config', async (route) => {
|
||||
139 | callLog.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON?.() });
|
||||
140 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
|
||||
141 | });
|
||||
142 | await page.route('**/api/channels/*/config', async (route) => {
|
||||
143 | const body = route.request().postDataJSON?.();
|
||||
144 | callLog.push({ method: route.request().method(), url: route.request().url(), body });
|
||||
145 | if (route.request().method() === 'GET') {
|
||||
146 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ channel_id: `telegram-${LONG}`, enabled: true, kind: 'telegram', mode: 'polling', account_id: `bot-${LONG}`, display_name: `Telegram ${LONG}`, config: { requireMentionInGroups: true }, secrets: { botToken: '***' } }) });
|
||||
147 | } else {
|
||||
148 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, channel_id: `telegram-${LONG}`, restart_required: true, channel: { channel_id: `telegram-${LONG}`, enabled: true, kind: 'telegram', mode: 'polling', account_id: `bot-${LONG}`, display_name: `Telegram ${LONG}`, config: body?.config || {}, secrets: {} } }) });
|
||||
149 | }
|
||||
150 | });
|
||||
151 | await page.route('**/api/channels/*/events**', async (route) => {
|
||||
152 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
153 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ event_id: `event-${LONG}`, channel_id: `telegram-${LONG}`, kind: 'message', status: `ok-${LONG}`, error: null, created_at: '2026-06-04T08:00:00Z' }]) });
|
||||
154 | });
|
||||
155 | await page.route('**/api/channel-connectors', async (route) => {
|
||||
156 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
157 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ kind: 'terminal', displayName: 'Terminal' }, { kind: 'telegram', displayName: 'Telegram' }, { kind: 'feishu', displayName: 'Feishu/Lark', authType: 'plugin' }]) });
|
||||
158 | });
|
||||
159 | await page.route('**/api/channel-connections', async (route) => {
|
||||
160 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
161 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ connection_id: `conn-${LONG}`, channel_id: `telegram-${LONG}`, kind: 'telegram', mode: 'polling', display_name: 'Telegram', account_id: `bot-${LONG}`, status: 'connected', auth_type: 'token' }]) });
|
||||
162 | });
|
||||
163 | await page.route('**/api/runtime/restart', async (route) => {
|
||||
164 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
165 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
|
||||
166 | });
|
||||
167 | }
|
||||
168 |
|
||||
169 | async function collectMetrics(page) {
|
||||
170 | return await page.evaluate(() => {
|
||||
171 | const viewportWidth = window.innerWidth;
|
||||
172 | const bodyWidth = document.body.scrollWidth;
|
||||
173 | const docWidth = document.documentElement.scrollWidth;
|
||||
174 | const overflowElements = Array.from(document.querySelectorAll('body *')).map((el) => {
|
||||
175 | const rect = el.getBoundingClientRect();
|
||||
176 | return { tag: el.tagName, text: (el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 100), left: rect.left, right: rect.right, width: rect.width, className: String(el.className || '') };
|
||||
177 | }).filter((item) => item.width > 1 && (item.left < -1 || item.right > viewportWidth + 1)).slice(0, 30);
|
||||
178 | const smallTargets = Array.from(document.querySelectorAll('button, a, input:not([type=hidden]), textarea, [role="button"], [role="switch"], [role="combobox"], [role="tab"]')).map((el) => {
|
||||
179 | const rect = el.getBoundingClientRect();
|
||||
180 | const style = window.getComputedStyle(el);
|
||||
181 | return { tag: el.tagName, text: (el.textContent || el.getAttribute('aria-label') || el.getAttribute('title') || '').trim().slice(0, 100), width: Math.round(rect.width), height: Math.round(rect.height), visible: style.visibility !== 'hidden' && style.display !== 'none' && Number(style.opacity) > 0.01 && rect.width > 0 && rect.height > 0 };
|
||||
182 | }).filter((item) => item.visible && (item.width < 44 || item.height < 44)).slice(0, 30);
|
||||
183 | return { viewportWidth, bodyWidth, docWidth, horizontalOverflow: Math.max(bodyWidth, docWidth) > viewportWidth + 1, overflowElements, smallTargets };
|
||||
184 | });
|
||||
185 | }
|
||||
186 |
|
||||
187 | test.describe('marketplace and settings QA', () => {
|
||||
188 | test('marketplace search detail file install flow works', async ({ page }) => {
|
||||
189 | await page.setViewportSize({ width: 390, height: 844 });
|
||||
190 | await installRoutes(page);
|
||||
191 | await page.goto(`${APP}/marketplace`);
|
||||
192 | await expect(page.getByPlaceholder(/搜索技能|Search skills/)).toBeVisible();
|
||||
193 | await page.getByPlaceholder(/搜索技能|Search skills/).fill('qa');
|
||||
194 | await page.getByRole('button', { name: /搜索|Search/ }).click();
|
||||
195 | await page.getByText(/SkillHub very-long-ui-token/).click();
|
||||
196 | await expect(page.getByRole('heading', { name: /SkillHub very-long-ui-token/ })).toBeVisible();
|
||||
197 | await page.getByRole('tab', { name: /文件|Files/ }).click();
|
||||
198 | await page.getByRole('button', { name: /src\/very-long-ui-token/ }).click();
|
||||
> 199 | await expect(page.getByText(/src\/very-long-ui-token/)).toBeVisible();
|
||||
| ^ Error: expect(locator).toBeVisible() failed
|
||||
200 | await page.getByRole('button', { name: /安装|Install/ }).click();
|
||||
201 | await expect.poll(() => callLog.some((call) => call.url.includes('/install') && call.method === 'POST')).toBe(true);
|
||||
202 | await page.screenshot({ path: path.join(SHOT_DIR, 'market-detail-390x844.png'), fullPage: true });
|
||||
203 | });
|
||||
204 |
|
||||
205 | test('settings agent provider channel and restart flows work', async ({ page }) => {
|
||||
206 | await page.setViewportSize({ width: 390, height: 844 });
|
||||
207 | await installRoutes(page);
|
||||
208 | await page.goto(`${APP}/settings`);
|
||||
209 | await expect(page.getByRole('heading', { name: /配置|Settings/ })).toBeVisible();
|
||||
210 | await page.getByLabel(/温度|Temperature/).fill('0.3');
|
||||
211 | await page.getByRole('button', { name: /保存智能体配置|Save agent config/ }).click();
|
||||
212 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/agent/config') && call.method !== 'GET')).toBe(true);
|
||||
213 | await page.getByRole('button', { name: /OpenAI/ }).click();
|
||||
214 | await expect(page.getByRole('dialog')).toBeVisible();
|
||||
215 | await page.getByLabel(/请求超时|Request timeout/).fill('60');
|
||||
216 | await page.getByRole('button', { name: /保存|Save/ }).click();
|
||||
217 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/providers/openai/config'))).toBe(true);
|
||||
218 | await page.getByRole('button', { name: /Telegram/ }).click();
|
||||
219 | await expect(page.getByRole('dialog')).toBeVisible();
|
||||
220 | await page.getByLabel(/显示名|Display name/).fill(`Telegram ${LONG}`);
|
||||
221 | await page.getByRole('button', { name: /保存通道配置|Save channel config/ }).click();
|
||||
222 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/channels/') && call.method !== 'GET')).toBe(true);
|
||||
223 | await page.screenshot({ path: path.join(SHOT_DIR, 'settings-channel-390x844.png'), fullPage: true });
|
||||
224 | await page.keyboard.press('Escape');
|
||||
225 | await page.getByRole('button', { name: /重启实例|Restart instance/ }).first().click();
|
||||
226 | await expect(page.getByRole('dialog')).toBeVisible();
|
||||
227 | await page.getByRole('button', { name: /^重启$|^Restart$/ }).click();
|
||||
228 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/runtime/restart'))).toBe(true);
|
||||
229 | });
|
||||
230 |
|
||||
231 | test('settings error state is readable', async ({ page }) => {
|
||||
232 | await page.setViewportSize({ width: 390, height: 844 });
|
||||
233 | await installRoutes(page, { statusError: true });
|
||||
234 | await page.goto(`${APP}/settings`);
|
||||
235 | await expect(page.getByText(/QA status error|无法连接|Unable to connect/)).toBeVisible();
|
||||
236 | await page.screenshot({ path: path.join(SHOT_DIR, 'settings-error-390x844.png'), fullPage: true });
|
||||
237 | const metrics = await collectMetrics(page);
|
||||
238 | expect(metrics.horizontalOverflow, JSON.stringify(metrics.overflowElements, null, 2)).toBe(false);
|
||||
239 | });
|
||||
240 |
|
||||
241 | test('responsive layouts have no page overflow or visible small targets', async ({ browser }) => {
|
||||
242 | const results = [];
|
||||
243 | for (const viewport of [
|
||||
244 | { width: 320, height: 568 },
|
||||
245 | { width: 390, height: 844 },
|
||||
246 | { width: 844, height: 390 },
|
||||
247 | { width: 768, height: 1024 },
|
||||
248 | { width: 1365, height: 900 },
|
||||
249 | ]) {
|
||||
250 | const market = await browser.newPage({ viewport });
|
||||
251 | await installRoutes(market);
|
||||
252 | await market.goto(`${APP}/marketplace`);
|
||||
253 | await market.getByText(/SkillHub very-long-ui-token/).click();
|
||||
254 | await market.screenshot({ path: path.join(SHOT_DIR, `market-${viewport.width}x${viewport.height}.png`), fullPage: true });
|
||||
255 | const marketMetrics = await collectMetrics(market);
|
||||
256 | await market.close();
|
||||
257 |
|
||||
258 | const settings = await browser.newPage({ viewport });
|
||||
259 | await installRoutes(settings);
|
||||
260 | await settings.goto(`${APP}/settings`);
|
||||
261 | await settings.getByRole('button', { name: /Telegram/ }).click();
|
||||
262 | await settings.screenshot({ path: path.join(SHOT_DIR, `settings-${viewport.width}x${viewport.height}.png`), fullPage: true });
|
||||
263 | const settingsMetrics = await collectMetrics(settings);
|
||||
264 | await settings.close();
|
||||
265 |
|
||||
266 | results.push({ viewport, marketMetrics, settingsMetrics });
|
||||
267 | expect(marketMetrics.horizontalOverflow, JSON.stringify(marketMetrics.overflowElements, null, 2)).toBe(false);
|
||||
268 | expect(settingsMetrics.horizontalOverflow, JSON.stringify(settingsMetrics.overflowElements, null, 2)).toBe(false);
|
||||
269 | expect(marketMetrics.smallTargets, JSON.stringify(marketMetrics.smallTargets, null, 2)).toEqual([]);
|
||||
270 | expect(settingsMetrics.smallTargets, JSON.stringify(settingsMetrics.smallTargets, null, 2)).toEqual([]);
|
||||
271 | }
|
||||
272 | fs.writeFileSync(REPORT, JSON.stringify({ results }, null, 2));
|
||||
273 | });
|
||||
274 | });
|
||||
275 |
|
||||
```
|
||||
@ -0,0 +1,208 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: market-settings-qa.spec.js >> marketplace and settings QA >> settings error state is readable
|
||||
- Location: ../../../../../../tmp/beaver-market-settings-qa/market-settings-qa.spec.js:231:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: expect(locator).toBeVisible() failed
|
||||
|
||||
Locator: getByText(/QA status error|无法连接|Unable to connect/)
|
||||
Expected: visible
|
||||
Error: strict mode violation: getByText(/QA status error|无法连接|Unable to connect/) resolved to 2 elements:
|
||||
1) <p class="font-medium">Unable to connect to the Boardware Agent Sandbox …</p> aka getByText('Unable to connect to the')
|
||||
2) <p class="text-sm text-muted-foreground mt-1">API error 500: QA status error</p> aka getByText('API error 500: QA status error')
|
||||
|
||||
Call log:
|
||||
- Expect "toBeVisible" with timeout 8000ms
|
||||
- waiting for getByText(/QA status error|无法连接|Unable to connect/)
|
||||
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [active] [ref=e1]:
|
||||
- generic [ref=e2]:
|
||||
- banner [ref=e3]:
|
||||
- generic [ref=e5]:
|
||||
- generic [ref=e6]:
|
||||
- button "Open navigation" [ref=e7] [cursor=pointer]:
|
||||
- img [ref=e8]
|
||||
- link "Beaver" [ref=e9] [cursor=pointer]:
|
||||
- /url: /
|
||||
- generic [ref=e10]: Beaver
|
||||
- generic [ref=e12]:
|
||||
- generic [ref=e13]:
|
||||
- img [ref=e14]
|
||||
- button "ZH" [ref=e18] [cursor=pointer]
|
||||
- button "EN" [ref=e19] [cursor=pointer]
|
||||
- button "Open account menu" [ref=e20] [cursor=pointer]:
|
||||
- generic [ref=e22]: U
|
||||
- main [ref=e23]:
|
||||
- generic [ref=e26]:
|
||||
- generic [ref=e27]:
|
||||
- img [ref=e28]
|
||||
- generic [ref=e30]:
|
||||
- paragraph [ref=e31]: Unable to connect to the Boardware Agent Sandbox backend
|
||||
- paragraph [ref=e32]: "API error 500: QA status error"
|
||||
- paragraph [ref=e33]: Please confirm the backend service is running and reachable from this page.
|
||||
- button "Retry" [ref=e34] [cursor=pointer]:
|
||||
- img [ref=e35]
|
||||
- text: Retry
|
||||
- alert [ref=e40]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
135 | callLog.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON?.() });
|
||||
136 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
|
||||
137 | });
|
||||
138 | await page.route('**/api/agent/config', async (route) => {
|
||||
139 | callLog.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON?.() });
|
||||
140 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
|
||||
141 | });
|
||||
142 | await page.route('**/api/channels/*/config', async (route) => {
|
||||
143 | const body = route.request().postDataJSON?.();
|
||||
144 | callLog.push({ method: route.request().method(), url: route.request().url(), body });
|
||||
145 | if (route.request().method() === 'GET') {
|
||||
146 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ channel_id: `telegram-${LONG}`, enabled: true, kind: 'telegram', mode: 'polling', account_id: `bot-${LONG}`, display_name: `Telegram ${LONG}`, config: { requireMentionInGroups: true }, secrets: { botToken: '***' } }) });
|
||||
147 | } else {
|
||||
148 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, channel_id: `telegram-${LONG}`, restart_required: true, channel: { channel_id: `telegram-${LONG}`, enabled: true, kind: 'telegram', mode: 'polling', account_id: `bot-${LONG}`, display_name: `Telegram ${LONG}`, config: body?.config || {}, secrets: {} } }) });
|
||||
149 | }
|
||||
150 | });
|
||||
151 | await page.route('**/api/channels/*/events**', async (route) => {
|
||||
152 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
153 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ event_id: `event-${LONG}`, channel_id: `telegram-${LONG}`, kind: 'message', status: `ok-${LONG}`, error: null, created_at: '2026-06-04T08:00:00Z' }]) });
|
||||
154 | });
|
||||
155 | await page.route('**/api/channel-connectors', async (route) => {
|
||||
156 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
157 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ kind: 'terminal', displayName: 'Terminal' }, { kind: 'telegram', displayName: 'Telegram' }, { kind: 'feishu', displayName: 'Feishu/Lark', authType: 'plugin' }]) });
|
||||
158 | });
|
||||
159 | await page.route('**/api/channel-connections', async (route) => {
|
||||
160 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
161 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ connection_id: `conn-${LONG}`, channel_id: `telegram-${LONG}`, kind: 'telegram', mode: 'polling', display_name: 'Telegram', account_id: `bot-${LONG}`, status: 'connected', auth_type: 'token' }]) });
|
||||
162 | });
|
||||
163 | await page.route('**/api/runtime/restart', async (route) => {
|
||||
164 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
165 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
|
||||
166 | });
|
||||
167 | }
|
||||
168 |
|
||||
169 | async function collectMetrics(page) {
|
||||
170 | return await page.evaluate(() => {
|
||||
171 | const viewportWidth = window.innerWidth;
|
||||
172 | const bodyWidth = document.body.scrollWidth;
|
||||
173 | const docWidth = document.documentElement.scrollWidth;
|
||||
174 | const overflowElements = Array.from(document.querySelectorAll('body *')).map((el) => {
|
||||
175 | const rect = el.getBoundingClientRect();
|
||||
176 | return { tag: el.tagName, text: (el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 100), left: rect.left, right: rect.right, width: rect.width, className: String(el.className || '') };
|
||||
177 | }).filter((item) => item.width > 1 && (item.left < -1 || item.right > viewportWidth + 1)).slice(0, 30);
|
||||
178 | const smallTargets = Array.from(document.querySelectorAll('button, a, input:not([type=hidden]), textarea, [role="button"], [role="switch"], [role="combobox"], [role="tab"]')).map((el) => {
|
||||
179 | const rect = el.getBoundingClientRect();
|
||||
180 | const style = window.getComputedStyle(el);
|
||||
181 | return { tag: el.tagName, text: (el.textContent || el.getAttribute('aria-label') || el.getAttribute('title') || '').trim().slice(0, 100), width: Math.round(rect.width), height: Math.round(rect.height), visible: style.visibility !== 'hidden' && style.display !== 'none' && Number(style.opacity) > 0.01 && rect.width > 0 && rect.height > 0 };
|
||||
182 | }).filter((item) => item.visible && (item.width < 44 || item.height < 44)).slice(0, 30);
|
||||
183 | return { viewportWidth, bodyWidth, docWidth, horizontalOverflow: Math.max(bodyWidth, docWidth) > viewportWidth + 1, overflowElements, smallTargets };
|
||||
184 | });
|
||||
185 | }
|
||||
186 |
|
||||
187 | test.describe('marketplace and settings QA', () => {
|
||||
188 | test('marketplace search detail file install flow works', async ({ page }) => {
|
||||
189 | await page.setViewportSize({ width: 390, height: 844 });
|
||||
190 | await installRoutes(page);
|
||||
191 | await page.goto(`${APP}/marketplace`);
|
||||
192 | await expect(page.getByPlaceholder(/搜索技能|Search skills/)).toBeVisible();
|
||||
193 | await page.getByPlaceholder(/搜索技能|Search skills/).fill('qa');
|
||||
194 | await page.getByRole('button', { name: /搜索|Search/ }).click();
|
||||
195 | await page.getByText(/SkillHub very-long-ui-token/).click();
|
||||
196 | await expect(page.getByRole('heading', { name: /SkillHub very-long-ui-token/ })).toBeVisible();
|
||||
197 | await page.getByRole('tab', { name: /文件|Files/ }).click();
|
||||
198 | await page.getByRole('button', { name: /src\/very-long-ui-token/ }).click();
|
||||
199 | await expect(page.getByText(/src\/very-long-ui-token/)).toBeVisible();
|
||||
200 | await page.getByRole('button', { name: /安装|Install/ }).click();
|
||||
201 | await expect.poll(() => callLog.some((call) => call.url.includes('/install') && call.method === 'POST')).toBe(true);
|
||||
202 | await page.screenshot({ path: path.join(SHOT_DIR, 'market-detail-390x844.png'), fullPage: true });
|
||||
203 | });
|
||||
204 |
|
||||
205 | test('settings agent provider channel and restart flows work', async ({ page }) => {
|
||||
206 | await page.setViewportSize({ width: 390, height: 844 });
|
||||
207 | await installRoutes(page);
|
||||
208 | await page.goto(`${APP}/settings`);
|
||||
209 | await expect(page.getByRole('heading', { name: /配置|Settings/ })).toBeVisible();
|
||||
210 | await page.getByLabel(/温度|Temperature/).fill('0.3');
|
||||
211 | await page.getByRole('button', { name: /保存智能体配置|Save agent config/ }).click();
|
||||
212 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/agent/config') && call.method !== 'GET')).toBe(true);
|
||||
213 | await page.getByRole('button', { name: /OpenAI/ }).click();
|
||||
214 | await expect(page.getByRole('dialog')).toBeVisible();
|
||||
215 | await page.getByLabel(/请求超时|Request timeout/).fill('60');
|
||||
216 | await page.getByRole('button', { name: /保存|Save/ }).click();
|
||||
217 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/providers/openai/config'))).toBe(true);
|
||||
218 | await page.getByRole('button', { name: /Telegram/ }).click();
|
||||
219 | await expect(page.getByRole('dialog')).toBeVisible();
|
||||
220 | await page.getByLabel(/显示名|Display name/).fill(`Telegram ${LONG}`);
|
||||
221 | await page.getByRole('button', { name: /保存通道配置|Save channel config/ }).click();
|
||||
222 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/channels/') && call.method !== 'GET')).toBe(true);
|
||||
223 | await page.screenshot({ path: path.join(SHOT_DIR, 'settings-channel-390x844.png'), fullPage: true });
|
||||
224 | await page.keyboard.press('Escape');
|
||||
225 | await page.getByRole('button', { name: /重启实例|Restart instance/ }).first().click();
|
||||
226 | await expect(page.getByRole('dialog')).toBeVisible();
|
||||
227 | await page.getByRole('button', { name: /^重启$|^Restart$/ }).click();
|
||||
228 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/runtime/restart'))).toBe(true);
|
||||
229 | });
|
||||
230 |
|
||||
231 | test('settings error state is readable', async ({ page }) => {
|
||||
232 | await page.setViewportSize({ width: 390, height: 844 });
|
||||
233 | await installRoutes(page, { statusError: true });
|
||||
234 | await page.goto(`${APP}/settings`);
|
||||
> 235 | await expect(page.getByText(/QA status error|无法连接|Unable to connect/)).toBeVisible();
|
||||
| ^ Error: expect(locator).toBeVisible() failed
|
||||
236 | await page.screenshot({ path: path.join(SHOT_DIR, 'settings-error-390x844.png'), fullPage: true });
|
||||
237 | const metrics = await collectMetrics(page);
|
||||
238 | expect(metrics.horizontalOverflow, JSON.stringify(metrics.overflowElements, null, 2)).toBe(false);
|
||||
239 | });
|
||||
240 |
|
||||
241 | test('responsive layouts have no page overflow or visible small targets', async ({ browser }) => {
|
||||
242 | const results = [];
|
||||
243 | for (const viewport of [
|
||||
244 | { width: 320, height: 568 },
|
||||
245 | { width: 390, height: 844 },
|
||||
246 | { width: 844, height: 390 },
|
||||
247 | { width: 768, height: 1024 },
|
||||
248 | { width: 1365, height: 900 },
|
||||
249 | ]) {
|
||||
250 | const market = await browser.newPage({ viewport });
|
||||
251 | await installRoutes(market);
|
||||
252 | await market.goto(`${APP}/marketplace`);
|
||||
253 | await market.getByText(/SkillHub very-long-ui-token/).click();
|
||||
254 | await market.screenshot({ path: path.join(SHOT_DIR, `market-${viewport.width}x${viewport.height}.png`), fullPage: true });
|
||||
255 | const marketMetrics = await collectMetrics(market);
|
||||
256 | await market.close();
|
||||
257 |
|
||||
258 | const settings = await browser.newPage({ viewport });
|
||||
259 | await installRoutes(settings);
|
||||
260 | await settings.goto(`${APP}/settings`);
|
||||
261 | await settings.getByRole('button', { name: /Telegram/ }).click();
|
||||
262 | await settings.screenshot({ path: path.join(SHOT_DIR, `settings-${viewport.width}x${viewport.height}.png`), fullPage: true });
|
||||
263 | const settingsMetrics = await collectMetrics(settings);
|
||||
264 | await settings.close();
|
||||
265 |
|
||||
266 | results.push({ viewport, marketMetrics, settingsMetrics });
|
||||
267 | expect(marketMetrics.horizontalOverflow, JSON.stringify(marketMetrics.overflowElements, null, 2)).toBe(false);
|
||||
268 | expect(settingsMetrics.horizontalOverflow, JSON.stringify(settingsMetrics.overflowElements, null, 2)).toBe(false);
|
||||
269 | expect(marketMetrics.smallTargets, JSON.stringify(marketMetrics.smallTargets, null, 2)).toEqual([]);
|
||||
270 | expect(settingsMetrics.smallTargets, JSON.stringify(settingsMetrics.smallTargets, null, 2)).toEqual([]);
|
||||
271 | }
|
||||
272 | fs.writeFileSync(REPORT, JSON.stringify({ results }, null, 2));
|
||||
273 | });
|
||||
274 | });
|
||||
275 |
|
||||
```
|
||||
@ -0,0 +1,316 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: market-settings-qa.spec.js >> marketplace and settings QA >> settings agent provider channel and restart flows work
|
||||
- Location: ../../../../../../tmp/beaver-market-settings-qa/market-settings-qa.spec.js:205:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: expect(received).toBe(expected) // Object.is equality
|
||||
|
||||
Expected: true
|
||||
Received: false
|
||||
|
||||
Call Log:
|
||||
- Timeout 8000ms exceeded while waiting on the predicate
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [active] [ref=e1]:
|
||||
- generic [ref=e2]:
|
||||
- banner [ref=e3]:
|
||||
- generic [ref=e5]:
|
||||
- generic [ref=e6]:
|
||||
- button "Open navigation" [ref=e7] [cursor=pointer]:
|
||||
- img [ref=e8]
|
||||
- link "Beaver" [ref=e9] [cursor=pointer]:
|
||||
- /url: /
|
||||
- generic [ref=e10]: Beaver
|
||||
- generic [ref=e12]:
|
||||
- generic [ref=e13]:
|
||||
- img [ref=e14]
|
||||
- button "ZH" [ref=e18] [cursor=pointer]
|
||||
- button "EN" [ref=e19] [cursor=pointer]
|
||||
- button "Open account menu" [ref=e20] [cursor=pointer]:
|
||||
- generic [ref=e22]: U
|
||||
- main [ref=e23]:
|
||||
- generic [ref=e24]:
|
||||
- generic [ref=e25]:
|
||||
- generic [ref=e26]:
|
||||
- heading "Settings" [level=1] [ref=e27]
|
||||
- paragraph [ref=e28]: Manage models, tools, integrations, and instance runtime status. Tasks and notifications stay in their own pages.
|
||||
- button "Refresh" [ref=e29] [cursor=pointer]:
|
||||
- img [ref=e30]
|
||||
- text: Refresh
|
||||
- generic [ref=e35]:
|
||||
- heading "Instance runtime" [level=3] [ref=e37]:
|
||||
- img [ref=e38]
|
||||
- text: Instance runtime
|
||||
- generic [ref=e41]:
|
||||
- generic [ref=e42]:
|
||||
- generic [ref=e43]:
|
||||
- paragraph [ref=e44]: Runtime and debugging
|
||||
- paragraph [ref=e45]: Inspect per-chat runtime logs and current instance status.
|
||||
- generic [ref=e46]:
|
||||
- link "Runtime Logs" [ref=e47] [cursor=pointer]:
|
||||
- /url: /logs
|
||||
- img [ref=e48]
|
||||
- text: Runtime Logs
|
||||
- button "Restart instance" [ref=e51] [cursor=pointer]:
|
||||
- img [ref=e52]
|
||||
- text: Restart instance
|
||||
- generic [ref=e57]:
|
||||
- generic [ref=e58]:
|
||||
- generic [ref=e59]: Config file
|
||||
- code [ref=e61]: /home/ivan/very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789/config/beaver.yml
|
||||
- generic [ref=e62]:
|
||||
- generic [ref=e63]: Workspace
|
||||
- code [ref=e65]: /home/ivan/very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789/workspace
|
||||
- generic [ref=e66]:
|
||||
- heading "Agent configuration" [level=3] [ref=e68]:
|
||||
- img [ref=e69]
|
||||
- text: Agent configuration
|
||||
- generic [ref=e72]:
|
||||
- generic [ref=e73]:
|
||||
- generic [ref=e74]: Model
|
||||
- code [ref=e76]: qwen-very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789
|
||||
- generic [ref=e77]:
|
||||
- generic [ref=e78]:
|
||||
- generic [ref=e79]: Max tokens
|
||||
- textbox "Max tokens" [ref=e80]:
|
||||
- /placeholder: Model default
|
||||
- text: "4096"
|
||||
- generic [ref=e81]:
|
||||
- generic [ref=e82]: Temperature
|
||||
- textbox "Temperature" [ref=e83]: "0.2"
|
||||
- generic [ref=e84]:
|
||||
- generic [ref=e85]: Max tool iterations
|
||||
- textbox "Max tool iterations" [ref=e86]: "30"
|
||||
- button "Save agent config" [ref=e88] [cursor=pointer]
|
||||
- generic [ref=e89]:
|
||||
- heading "Providers" [level=3] [ref=e91]:
|
||||
- img [ref=e92]
|
||||
- text: Providers
|
||||
- generic [ref=e97]:
|
||||
- button "OpenAI very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789 Current default sk-********" [ref=e98] [cursor=pointer]:
|
||||
- generic [ref=e99]:
|
||||
- generic [ref=e100]:
|
||||
- img [ref=e101]
|
||||
- generic [ref=e104]: OpenAI very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789
|
||||
- generic [ref=e105]: Current default
|
||||
- generic [ref=e106]: sk-********
|
||||
- img [ref=e107]
|
||||
- button "DeepSeek Click to configure" [ref=e110] [cursor=pointer]:
|
||||
- generic [ref=e111]:
|
||||
- generic [ref=e112]:
|
||||
- img [ref=e113]
|
||||
- generic [ref=e117]: DeepSeek
|
||||
- generic [ref=e118]: Click to configure
|
||||
- img [ref=e119]
|
||||
- generic [ref=e122]:
|
||||
- heading "Channels" [level=3] [ref=e124]:
|
||||
- img [ref=e125]
|
||||
- text: Channels
|
||||
- generic [ref=e133]:
|
||||
- button "Weixin QR Connect" [ref=e134] [cursor=pointer]:
|
||||
- generic [ref=e135]:
|
||||
- generic [ref=e136]:
|
||||
- img [ref=e137]
|
||||
- generic [ref=e143]: Weixin
|
||||
- generic [ref=e144]: QR
|
||||
- generic [ref=e145]: Connect
|
||||
- button "Feishu/Lark plugin Connect" [ref=e146] [cursor=pointer]:
|
||||
- generic [ref=e147]:
|
||||
- generic [ref=e148]:
|
||||
- img [ref=e149]
|
||||
- generic [ref=e155]: Feishu/Lark
|
||||
- generic [ref=e156]: plugin
|
||||
- generic [ref=e157]: Connect
|
||||
- button "Terminal Local terminal connection Connection instructions Guide" [ref=e158] [cursor=pointer]:
|
||||
- generic [ref=e159]:
|
||||
- generic [ref=e160]:
|
||||
- img [ref=e161]
|
||||
- generic [ref=e167]: Terminal
|
||||
- generic [ref=e168]: Local terminal connection
|
||||
- generic [ref=e169]: Connection instructions
|
||||
- generic [ref=e170]: Guide
|
||||
- alert [ref=e171]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
112 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ detail: { version: '1.0.0-alpha-long-version-name-0123456789', status: 'published', parsedMetadataJson: JSON.stringify({ body: `# Skill\n\n${LONG_TEXT}` }) }, files: [{ filePath: 'SKILL.md', fileSize: 2048, contentType: 'text/markdown' }, { filePath: `src/${LONG}/implementation.ts`, fileSize: 4096, contentType: 'text/plain' }] }) });
|
||||
113 | });
|
||||
114 | await page.route('**/api/marketplaces/skills/*/*/versions', async (route) => {
|
||||
115 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
116 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [{ version: '1.0.0-alpha-long-version-name-0123456789', status: 'published', createdAt: '2026-06-04T08:00:00Z', changeReason: LONG_TEXT }], total: 1, page: 0, size: 20 }) });
|
||||
117 | });
|
||||
118 | await page.route('**/api/marketplaces/skills/*/*/install', async (route) => {
|
||||
119 | callLog.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON?.() });
|
||||
120 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, skill_name: `skill-${LONG}`, version: '1.0.0-alpha-long-version-name-0123456789', source: 'skillhub', namespace: `team-${LONG}` }) });
|
||||
121 | });
|
||||
122 | await page.route('**/api/marketplaces/skills/*/*', async (route) => {
|
||||
123 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
124 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(skillItem()) });
|
||||
125 | });
|
||||
126 | await page.route('**/api/status', async (route) => {
|
||||
127 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
128 | if (statusError) {
|
||||
129 | await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ detail: 'QA status error' }) });
|
||||
130 | return;
|
||||
131 | }
|
||||
132 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(statusPayload()) });
|
||||
133 | });
|
||||
134 | await page.route('**/api/providers/*/config', async (route) => {
|
||||
135 | callLog.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON?.() });
|
||||
136 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
|
||||
137 | });
|
||||
138 | await page.route('**/api/agent/config', async (route) => {
|
||||
139 | callLog.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON?.() });
|
||||
140 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
|
||||
141 | });
|
||||
142 | await page.route('**/api/channels/*/config', async (route) => {
|
||||
143 | const body = route.request().postDataJSON?.();
|
||||
144 | callLog.push({ method: route.request().method(), url: route.request().url(), body });
|
||||
145 | if (route.request().method() === 'GET') {
|
||||
146 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ channel_id: `telegram-${LONG}`, enabled: true, kind: 'telegram', mode: 'polling', account_id: `bot-${LONG}`, display_name: `Telegram ${LONG}`, config: { requireMentionInGroups: true }, secrets: { botToken: '***' } }) });
|
||||
147 | } else {
|
||||
148 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true, channel_id: `telegram-${LONG}`, restart_required: true, channel: { channel_id: `telegram-${LONG}`, enabled: true, kind: 'telegram', mode: 'polling', account_id: `bot-${LONG}`, display_name: `Telegram ${LONG}`, config: body?.config || {}, secrets: {} } }) });
|
||||
149 | }
|
||||
150 | });
|
||||
151 | await page.route('**/api/channels/*/events**', async (route) => {
|
||||
152 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
153 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ event_id: `event-${LONG}`, channel_id: `telegram-${LONG}`, kind: 'message', status: `ok-${LONG}`, error: null, created_at: '2026-06-04T08:00:00Z' }]) });
|
||||
154 | });
|
||||
155 | await page.route('**/api/channel-connectors', async (route) => {
|
||||
156 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
157 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ kind: 'terminal', displayName: 'Terminal' }, { kind: 'telegram', displayName: 'Telegram' }, { kind: 'feishu', displayName: 'Feishu/Lark', authType: 'plugin' }]) });
|
||||
158 | });
|
||||
159 | await page.route('**/api/channel-connections', async (route) => {
|
||||
160 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
161 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ connection_id: `conn-${LONG}`, channel_id: `telegram-${LONG}`, kind: 'telegram', mode: 'polling', display_name: 'Telegram', account_id: `bot-${LONG}`, status: 'connected', auth_type: 'token' }]) });
|
||||
162 | });
|
||||
163 | await page.route('**/api/runtime/restart', async (route) => {
|
||||
164 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
165 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
|
||||
166 | });
|
||||
167 | }
|
||||
168 |
|
||||
169 | async function collectMetrics(page) {
|
||||
170 | return await page.evaluate(() => {
|
||||
171 | const viewportWidth = window.innerWidth;
|
||||
172 | const bodyWidth = document.body.scrollWidth;
|
||||
173 | const docWidth = document.documentElement.scrollWidth;
|
||||
174 | const overflowElements = Array.from(document.querySelectorAll('body *')).map((el) => {
|
||||
175 | const rect = el.getBoundingClientRect();
|
||||
176 | return { tag: el.tagName, text: (el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 100), left: rect.left, right: rect.right, width: rect.width, className: String(el.className || '') };
|
||||
177 | }).filter((item) => item.width > 1 && (item.left < -1 || item.right > viewportWidth + 1)).slice(0, 30);
|
||||
178 | const smallTargets = Array.from(document.querySelectorAll('button, a, input:not([type=hidden]), textarea, [role="button"], [role="switch"], [role="combobox"], [role="tab"]')).map((el) => {
|
||||
179 | const rect = el.getBoundingClientRect();
|
||||
180 | const style = window.getComputedStyle(el);
|
||||
181 | return { tag: el.tagName, text: (el.textContent || el.getAttribute('aria-label') || el.getAttribute('title') || '').trim().slice(0, 100), width: Math.round(rect.width), height: Math.round(rect.height), visible: style.visibility !== 'hidden' && style.display !== 'none' && Number(style.opacity) > 0.01 && rect.width > 0 && rect.height > 0 };
|
||||
182 | }).filter((item) => item.visible && (item.width < 44 || item.height < 44)).slice(0, 30);
|
||||
183 | return { viewportWidth, bodyWidth, docWidth, horizontalOverflow: Math.max(bodyWidth, docWidth) > viewportWidth + 1, overflowElements, smallTargets };
|
||||
184 | });
|
||||
185 | }
|
||||
186 |
|
||||
187 | test.describe('marketplace and settings QA', () => {
|
||||
188 | test('marketplace search detail file install flow works', async ({ page }) => {
|
||||
189 | await page.setViewportSize({ width: 390, height: 844 });
|
||||
190 | await installRoutes(page);
|
||||
191 | await page.goto(`${APP}/marketplace`);
|
||||
192 | await expect(page.getByPlaceholder(/搜索技能|Search skills/)).toBeVisible();
|
||||
193 | await page.getByPlaceholder(/搜索技能|Search skills/).fill('qa');
|
||||
194 | await page.getByRole('button', { name: /搜索|Search/ }).click();
|
||||
195 | await page.getByText(/SkillHub very-long-ui-token/).click();
|
||||
196 | await expect(page.getByRole('heading', { name: /SkillHub very-long-ui-token/ })).toBeVisible();
|
||||
197 | await page.getByRole('tab', { name: /文件|Files/ }).click();
|
||||
198 | await page.getByRole('button', { name: /src\/very-long-ui-token/ }).click();
|
||||
199 | await expect(page.getByText(/src\/very-long-ui-token/)).toBeVisible();
|
||||
200 | await page.getByRole('button', { name: /安装|Install/ }).click();
|
||||
201 | await expect.poll(() => callLog.some((call) => call.url.includes('/install') && call.method === 'POST')).toBe(true);
|
||||
202 | await page.screenshot({ path: path.join(SHOT_DIR, 'market-detail-390x844.png'), fullPage: true });
|
||||
203 | });
|
||||
204 |
|
||||
205 | test('settings agent provider channel and restart flows work', async ({ page }) => {
|
||||
206 | await page.setViewportSize({ width: 390, height: 844 });
|
||||
207 | await installRoutes(page);
|
||||
208 | await page.goto(`${APP}/settings`);
|
||||
209 | await expect(page.getByRole('heading', { name: /配置|Settings/ })).toBeVisible();
|
||||
210 | await page.getByLabel(/温度|Temperature/).fill('0.3');
|
||||
211 | await page.getByRole('button', { name: /保存智能体配置|Save agent config/ }).click();
|
||||
> 212 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/agent/config') && call.method !== 'GET')).toBe(true);
|
||||
| ^ Error: expect(received).toBe(expected) // Object.is equality
|
||||
213 | await page.getByRole('button', { name: /OpenAI/ }).click();
|
||||
214 | await expect(page.getByRole('dialog')).toBeVisible();
|
||||
215 | await page.getByLabel(/请求超时|Request timeout/).fill('60');
|
||||
216 | await page.getByRole('button', { name: /保存|Save/ }).click();
|
||||
217 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/providers/openai/config'))).toBe(true);
|
||||
218 | await page.getByRole('button', { name: /Telegram/ }).click();
|
||||
219 | await expect(page.getByRole('dialog')).toBeVisible();
|
||||
220 | await page.getByLabel(/显示名|Display name/).fill(`Telegram ${LONG}`);
|
||||
221 | await page.getByRole('button', { name: /保存通道配置|Save channel config/ }).click();
|
||||
222 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/channels/') && call.method !== 'GET')).toBe(true);
|
||||
223 | await page.screenshot({ path: path.join(SHOT_DIR, 'settings-channel-390x844.png'), fullPage: true });
|
||||
224 | await page.keyboard.press('Escape');
|
||||
225 | await page.getByRole('button', { name: /重启实例|Restart instance/ }).first().click();
|
||||
226 | await expect(page.getByRole('dialog')).toBeVisible();
|
||||
227 | await page.getByRole('button', { name: /^重启$|^Restart$/ }).click();
|
||||
228 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/runtime/restart'))).toBe(true);
|
||||
229 | });
|
||||
230 |
|
||||
231 | test('settings error state is readable', async ({ page }) => {
|
||||
232 | await page.setViewportSize({ width: 390, height: 844 });
|
||||
233 | await installRoutes(page, { statusError: true });
|
||||
234 | await page.goto(`${APP}/settings`);
|
||||
235 | await expect(page.getByText(/QA status error|无法连接|Unable to connect/)).toBeVisible();
|
||||
236 | await page.screenshot({ path: path.join(SHOT_DIR, 'settings-error-390x844.png'), fullPage: true });
|
||||
237 | const metrics = await collectMetrics(page);
|
||||
238 | expect(metrics.horizontalOverflow, JSON.stringify(metrics.overflowElements, null, 2)).toBe(false);
|
||||
239 | });
|
||||
240 |
|
||||
241 | test('responsive layouts have no page overflow or visible small targets', async ({ browser }) => {
|
||||
242 | const results = [];
|
||||
243 | for (const viewport of [
|
||||
244 | { width: 320, height: 568 },
|
||||
245 | { width: 390, height: 844 },
|
||||
246 | { width: 844, height: 390 },
|
||||
247 | { width: 768, height: 1024 },
|
||||
248 | { width: 1365, height: 900 },
|
||||
249 | ]) {
|
||||
250 | const market = await browser.newPage({ viewport });
|
||||
251 | await installRoutes(market);
|
||||
252 | await market.goto(`${APP}/marketplace`);
|
||||
253 | await market.getByText(/SkillHub very-long-ui-token/).click();
|
||||
254 | await market.screenshot({ path: path.join(SHOT_DIR, `market-${viewport.width}x${viewport.height}.png`), fullPage: true });
|
||||
255 | const marketMetrics = await collectMetrics(market);
|
||||
256 | await market.close();
|
||||
257 |
|
||||
258 | const settings = await browser.newPage({ viewport });
|
||||
259 | await installRoutes(settings);
|
||||
260 | await settings.goto(`${APP}/settings`);
|
||||
261 | await settings.getByRole('button', { name: /Telegram/ }).click();
|
||||
262 | await settings.screenshot({ path: path.join(SHOT_DIR, `settings-${viewport.width}x${viewport.height}.png`), fullPage: true });
|
||||
263 | const settingsMetrics = await collectMetrics(settings);
|
||||
264 | await settings.close();
|
||||
265 |
|
||||
266 | results.push({ viewport, marketMetrics, settingsMetrics });
|
||||
267 | expect(marketMetrics.horizontalOverflow, JSON.stringify(marketMetrics.overflowElements, null, 2)).toBe(false);
|
||||
268 | expect(settingsMetrics.horizontalOverflow, JSON.stringify(settingsMetrics.overflowElements, null, 2)).toBe(false);
|
||||
269 | expect(marketMetrics.smallTargets, JSON.stringify(marketMetrics.smallTargets, null, 2)).toEqual([]);
|
||||
270 | expect(settingsMetrics.smallTargets, JSON.stringify(settingsMetrics.smallTargets, null, 2)).toEqual([]);
|
||||
271 | }
|
||||
272 | fs.writeFileSync(REPORT, JSON.stringify({ results }, null, 2));
|
||||
273 | });
|
||||
274 | });
|
||||
275 |
|
||||
```
|
||||
@ -0,0 +1,260 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: market-settings-qa.spec.js >> marketplace and settings QA >> responsive layouts have no page overflow or visible small targets
|
||||
- Location: ../../../../../../tmp/beaver-market-settings-qa/market-settings-qa.spec.js:241:3
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
TimeoutError: locator.click: Timeout 8000ms exceeded.
|
||||
Call log:
|
||||
- waiting for getByRole('button', { name: /Telegram/ })
|
||||
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [active] [ref=e1]:
|
||||
- generic [ref=e2]:
|
||||
- banner [ref=e3]:
|
||||
- generic [ref=e5]:
|
||||
- button "Open navigation" [ref=e7] [cursor=pointer]:
|
||||
- img [ref=e8]
|
||||
- generic [ref=e10]:
|
||||
- generic [ref=e11]:
|
||||
- img [ref=e12]
|
||||
- button "ZH" [ref=e16] [cursor=pointer]
|
||||
- button "EN" [ref=e17] [cursor=pointer]
|
||||
- button "Open account menu" [ref=e18] [cursor=pointer]:
|
||||
- generic [ref=e20]: U
|
||||
- main [ref=e21]:
|
||||
- generic [ref=e22]:
|
||||
- generic [ref=e23]:
|
||||
- generic [ref=e24]:
|
||||
- heading "Settings" [level=1] [ref=e25]
|
||||
- paragraph [ref=e26]: Manage models, tools, integrations, and instance runtime status. Tasks and notifications stay in their own pages.
|
||||
- button "Refresh" [ref=e27] [cursor=pointer]:
|
||||
- img [ref=e28]
|
||||
- text: Refresh
|
||||
- generic [ref=e33]:
|
||||
- heading "Instance runtime" [level=3] [ref=e35]:
|
||||
- img [ref=e36]
|
||||
- text: Instance runtime
|
||||
- generic [ref=e39]:
|
||||
- generic [ref=e40]:
|
||||
- generic [ref=e41]:
|
||||
- paragraph [ref=e42]: Runtime and debugging
|
||||
- paragraph [ref=e43]: Inspect per-chat runtime logs and current instance status.
|
||||
- generic [ref=e44]:
|
||||
- link "Runtime Logs" [ref=e45] [cursor=pointer]:
|
||||
- /url: /logs
|
||||
- img [ref=e46]
|
||||
- text: Runtime Logs
|
||||
- button "Restart instance" [ref=e49] [cursor=pointer]:
|
||||
- img [ref=e50]
|
||||
- text: Restart instance
|
||||
- generic [ref=e55]:
|
||||
- generic [ref=e56]:
|
||||
- generic [ref=e57]: Config file
|
||||
- code [ref=e59]: /home/ivan/very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789/config/beaver.yml
|
||||
- generic [ref=e60]:
|
||||
- generic [ref=e61]: Workspace
|
||||
- code [ref=e63]: /home/ivan/very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789/workspace
|
||||
- generic [ref=e64]:
|
||||
- heading "Agent configuration" [level=3] [ref=e66]:
|
||||
- img [ref=e67]
|
||||
- text: Agent configuration
|
||||
- generic [ref=e70]:
|
||||
- generic [ref=e71]:
|
||||
- generic [ref=e72]: Model
|
||||
- code [ref=e74]: qwen-very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789
|
||||
- generic [ref=e75]:
|
||||
- generic [ref=e76]:
|
||||
- generic [ref=e77]: Max tokens
|
||||
- textbox "Max tokens" [ref=e78]:
|
||||
- /placeholder: Model default
|
||||
- text: "4096"
|
||||
- generic [ref=e79]:
|
||||
- generic [ref=e80]: Temperature
|
||||
- textbox "Temperature" [ref=e81]: "0.2"
|
||||
- generic [ref=e82]:
|
||||
- generic [ref=e83]: Max tool iterations
|
||||
- textbox "Max tool iterations" [ref=e84]: "30"
|
||||
- button "Save agent config" [ref=e86] [cursor=pointer]
|
||||
- generic [ref=e87]:
|
||||
- heading "Providers" [level=3] [ref=e89]:
|
||||
- img [ref=e90]
|
||||
- text: Providers
|
||||
- generic [ref=e95]:
|
||||
- button "OpenAI very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789 Current default sk-********" [ref=e96] [cursor=pointer]:
|
||||
- generic [ref=e97]:
|
||||
- generic [ref=e98]:
|
||||
- img [ref=e99]
|
||||
- generic [ref=e102]: OpenAI very-long-ui-token-0123456789abcdefghijklmnopqrstuvwxyz-0123456789
|
||||
- generic [ref=e103]: Current default
|
||||
- generic [ref=e104]: sk-********
|
||||
- img [ref=e105]
|
||||
- button "DeepSeek Click to configure" [ref=e108] [cursor=pointer]:
|
||||
- generic [ref=e109]:
|
||||
- generic [ref=e110]:
|
||||
- img [ref=e111]
|
||||
- generic [ref=e115]: DeepSeek
|
||||
- generic [ref=e116]: Click to configure
|
||||
- img [ref=e117]
|
||||
- generic [ref=e120]:
|
||||
- heading "Channels" [level=3] [ref=e122]:
|
||||
- img [ref=e123]
|
||||
- text: Channels
|
||||
- generic [ref=e131]:
|
||||
- button "Weixin QR Connect" [ref=e132] [cursor=pointer]:
|
||||
- generic [ref=e133]:
|
||||
- generic [ref=e134]:
|
||||
- img [ref=e135]
|
||||
- generic [ref=e141]: Weixin
|
||||
- generic [ref=e142]: QR
|
||||
- generic [ref=e143]: Connect
|
||||
- button "Feishu/Lark plugin Connect" [ref=e144] [cursor=pointer]:
|
||||
- generic [ref=e145]:
|
||||
- generic [ref=e146]:
|
||||
- img [ref=e147]
|
||||
- generic [ref=e153]: Feishu/Lark
|
||||
- generic [ref=e154]: plugin
|
||||
- generic [ref=e155]: Connect
|
||||
- button "Terminal Local terminal connection Connection instructions Guide" [ref=e156] [cursor=pointer]:
|
||||
- generic [ref=e157]:
|
||||
- generic [ref=e158]:
|
||||
- img [ref=e159]
|
||||
- generic [ref=e165]: Terminal
|
||||
- generic [ref=e166]: Local terminal connection
|
||||
- generic [ref=e167]: Connection instructions
|
||||
- generic [ref=e168]: Guide
|
||||
- alert [ref=e169]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
161 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([{ connection_id: `conn-${LONG}`, channel_id: `telegram-${LONG}`, kind: 'telegram', mode: 'polling', display_name: 'Telegram', account_id: `bot-${LONG}`, status: 'connected', auth_type: 'token' }]) });
|
||||
162 | });
|
||||
163 | await page.route('**/api/runtime/restart', async (route) => {
|
||||
164 | callLog.push({ method: route.request().method(), url: route.request().url() });
|
||||
165 | await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) });
|
||||
166 | });
|
||||
167 | }
|
||||
168 |
|
||||
169 | async function collectMetrics(page) {
|
||||
170 | return await page.evaluate(() => {
|
||||
171 | const viewportWidth = window.innerWidth;
|
||||
172 | const bodyWidth = document.body.scrollWidth;
|
||||
173 | const docWidth = document.documentElement.scrollWidth;
|
||||
174 | const overflowElements = Array.from(document.querySelectorAll('body *')).map((el) => {
|
||||
175 | const rect = el.getBoundingClientRect();
|
||||
176 | return { tag: el.tagName, text: (el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 100), left: rect.left, right: rect.right, width: rect.width, className: String(el.className || '') };
|
||||
177 | }).filter((item) => item.width > 1 && (item.left < -1 || item.right > viewportWidth + 1)).slice(0, 30);
|
||||
178 | const smallTargets = Array.from(document.querySelectorAll('button, a, input:not([type=hidden]), textarea, [role="button"], [role="switch"], [role="combobox"], [role="tab"]')).map((el) => {
|
||||
179 | const rect = el.getBoundingClientRect();
|
||||
180 | const style = window.getComputedStyle(el);
|
||||
181 | return { tag: el.tagName, text: (el.textContent || el.getAttribute('aria-label') || el.getAttribute('title') || '').trim().slice(0, 100), width: Math.round(rect.width), height: Math.round(rect.height), visible: style.visibility !== 'hidden' && style.display !== 'none' && Number(style.opacity) > 0.01 && rect.width > 0 && rect.height > 0 };
|
||||
182 | }).filter((item) => item.visible && (item.width < 44 || item.height < 44)).slice(0, 30);
|
||||
183 | return { viewportWidth, bodyWidth, docWidth, horizontalOverflow: Math.max(bodyWidth, docWidth) > viewportWidth + 1, overflowElements, smallTargets };
|
||||
184 | });
|
||||
185 | }
|
||||
186 |
|
||||
187 | test.describe('marketplace and settings QA', () => {
|
||||
188 | test('marketplace search detail file install flow works', async ({ page }) => {
|
||||
189 | await page.setViewportSize({ width: 390, height: 844 });
|
||||
190 | await installRoutes(page);
|
||||
191 | await page.goto(`${APP}/marketplace`);
|
||||
192 | await expect(page.getByPlaceholder(/搜索技能|Search skills/)).toBeVisible();
|
||||
193 | await page.getByPlaceholder(/搜索技能|Search skills/).fill('qa');
|
||||
194 | await page.getByRole('button', { name: /搜索|Search/ }).click();
|
||||
195 | await page.getByText(/SkillHub very-long-ui-token/).click();
|
||||
196 | await expect(page.getByRole('heading', { name: /SkillHub very-long-ui-token/ })).toBeVisible();
|
||||
197 | await page.getByRole('tab', { name: /文件|Files/ }).click();
|
||||
198 | await page.getByRole('button', { name: /src\/very-long-ui-token/ }).click();
|
||||
199 | await expect(page.getByText(/src\/very-long-ui-token/)).toBeVisible();
|
||||
200 | await page.getByRole('button', { name: /安装|Install/ }).click();
|
||||
201 | await expect.poll(() => callLog.some((call) => call.url.includes('/install') && call.method === 'POST')).toBe(true);
|
||||
202 | await page.screenshot({ path: path.join(SHOT_DIR, 'market-detail-390x844.png'), fullPage: true });
|
||||
203 | });
|
||||
204 |
|
||||
205 | test('settings agent provider channel and restart flows work', async ({ page }) => {
|
||||
206 | await page.setViewportSize({ width: 390, height: 844 });
|
||||
207 | await installRoutes(page);
|
||||
208 | await page.goto(`${APP}/settings`);
|
||||
209 | await expect(page.getByRole('heading', { name: /配置|Settings/ })).toBeVisible();
|
||||
210 | await page.getByLabel(/温度|Temperature/).fill('0.3');
|
||||
211 | await page.getByRole('button', { name: /保存智能体配置|Save agent config/ }).click();
|
||||
212 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/agent/config') && call.method !== 'GET')).toBe(true);
|
||||
213 | await page.getByRole('button', { name: /OpenAI/ }).click();
|
||||
214 | await expect(page.getByRole('dialog')).toBeVisible();
|
||||
215 | await page.getByLabel(/请求超时|Request timeout/).fill('60');
|
||||
216 | await page.getByRole('button', { name: /保存|Save/ }).click();
|
||||
217 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/providers/openai/config'))).toBe(true);
|
||||
218 | await page.getByRole('button', { name: /Telegram/ }).click();
|
||||
219 | await expect(page.getByRole('dialog')).toBeVisible();
|
||||
220 | await page.getByLabel(/显示名|Display name/).fill(`Telegram ${LONG}`);
|
||||
221 | await page.getByRole('button', { name: /保存通道配置|Save channel config/ }).click();
|
||||
222 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/channels/') && call.method !== 'GET')).toBe(true);
|
||||
223 | await page.screenshot({ path: path.join(SHOT_DIR, 'settings-channel-390x844.png'), fullPage: true });
|
||||
224 | await page.keyboard.press('Escape');
|
||||
225 | await page.getByRole('button', { name: /重启实例|Restart instance/ }).first().click();
|
||||
226 | await expect(page.getByRole('dialog')).toBeVisible();
|
||||
227 | await page.getByRole('button', { name: /^重启$|^Restart$/ }).click();
|
||||
228 | await expect.poll(() => callLog.some((call) => call.url.includes('/api/runtime/restart'))).toBe(true);
|
||||
229 | });
|
||||
230 |
|
||||
231 | test('settings error state is readable', async ({ page }) => {
|
||||
232 | await page.setViewportSize({ width: 390, height: 844 });
|
||||
233 | await installRoutes(page, { statusError: true });
|
||||
234 | await page.goto(`${APP}/settings`);
|
||||
235 | await expect(page.getByText(/QA status error|无法连接|Unable to connect/)).toBeVisible();
|
||||
236 | await page.screenshot({ path: path.join(SHOT_DIR, 'settings-error-390x844.png'), fullPage: true });
|
||||
237 | const metrics = await collectMetrics(page);
|
||||
238 | expect(metrics.horizontalOverflow, JSON.stringify(metrics.overflowElements, null, 2)).toBe(false);
|
||||
239 | });
|
||||
240 |
|
||||
241 | test('responsive layouts have no page overflow or visible small targets', async ({ browser }) => {
|
||||
242 | const results = [];
|
||||
243 | for (const viewport of [
|
||||
244 | { width: 320, height: 568 },
|
||||
245 | { width: 390, height: 844 },
|
||||
246 | { width: 844, height: 390 },
|
||||
247 | { width: 768, height: 1024 },
|
||||
248 | { width: 1365, height: 900 },
|
||||
249 | ]) {
|
||||
250 | const market = await browser.newPage({ viewport });
|
||||
251 | await installRoutes(market);
|
||||
252 | await market.goto(`${APP}/marketplace`);
|
||||
253 | await market.getByText(/SkillHub very-long-ui-token/).click();
|
||||
254 | await market.screenshot({ path: path.join(SHOT_DIR, `market-${viewport.width}x${viewport.height}.png`), fullPage: true });
|
||||
255 | const marketMetrics = await collectMetrics(market);
|
||||
256 | await market.close();
|
||||
257 |
|
||||
258 | const settings = await browser.newPage({ viewport });
|
||||
259 | await installRoutes(settings);
|
||||
260 | await settings.goto(`${APP}/settings`);
|
||||
> 261 | await settings.getByRole('button', { name: /Telegram/ }).click();
|
||||
| ^ TimeoutError: locator.click: Timeout 8000ms exceeded.
|
||||
262 | await settings.screenshot({ path: path.join(SHOT_DIR, `settings-${viewport.width}x${viewport.height}.png`), fullPage: true });
|
||||
263 | const settingsMetrics = await collectMetrics(settings);
|
||||
264 | await settings.close();
|
||||
265 |
|
||||
266 | results.push({ viewport, marketMetrics, settingsMetrics });
|
||||
267 | expect(marketMetrics.horizontalOverflow, JSON.stringify(marketMetrics.overflowElements, null, 2)).toBe(false);
|
||||
268 | expect(settingsMetrics.horizontalOverflow, JSON.stringify(settingsMetrics.overflowElements, null, 2)).toBe(false);
|
||||
269 | expect(marketMetrics.smallTargets, JSON.stringify(marketMetrics.smallTargets, null, 2)).toEqual([]);
|
||||
270 | expect(settingsMetrics.smallTargets, JSON.stringify(settingsMetrics.smallTargets, null, 2)).toEqual([]);
|
||||
271 | }
|
||||
272 | fs.writeFileSync(REPORT, JSON.stringify({ results }, null, 2));
|
||||
273 | });
|
||||
274 | });
|
||||
275 |
|
||||
```
|
||||
@ -31,6 +31,7 @@ html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
@ -56,7 +57,7 @@ select {
|
||||
|
||||
.portal-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px;
|
||||
@ -68,7 +69,7 @@ select {
|
||||
|
||||
.auth-page {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
@ -122,11 +123,11 @@ select {
|
||||
|
||||
.ghost-icon-button {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: #a29d99;
|
||||
@ -203,6 +204,9 @@ select {
|
||||
|
||||
.login-footer a {
|
||||
justify-self: start;
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.portal-toolbar {
|
||||
@ -234,8 +238,8 @@ select {
|
||||
}
|
||||
|
||||
.language-switcher button {
|
||||
min-width: 34px;
|
||||
height: 28px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
color: var(--zinc-600);
|
||||
@ -524,7 +528,7 @@ select {
|
||||
|
||||
.auth-page .auth-card.login-card {
|
||||
width: 100%;
|
||||
max-height: calc(100vh - clamp(48px, 10vh, 112px));
|
||||
max-height: calc(100dvh - clamp(48px, 10vh, 112px));
|
||||
padding: clamp(30px, 5vh, 54px) clamp(24px, 3.2vw, 44px) clamp(26px, 4vh, 40px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -578,6 +582,14 @@ select {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.auth-page .login-field-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--zinc-600);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.auth-page .login-field input,
|
||||
.auth-page .login-field select {
|
||||
min-height: clamp(50px, 6vh, 60px);
|
||||
@ -680,7 +692,7 @@ select {
|
||||
.auth-page .auth-card.login-card {
|
||||
min-height: auto;
|
||||
padding: 34px 22px 28px;
|
||||
max-height: calc(100vh - 104px);
|
||||
max-height: calc(100dvh - 104px);
|
||||
}
|
||||
|
||||
.auth-page .login-logo {
|
||||
@ -722,3 +734,134 @@ select {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) and (max-height: 700px) {
|
||||
.auth-page {
|
||||
padding: 76px 16px 12px;
|
||||
}
|
||||
|
||||
.auth-page .auth-card.login-card {
|
||||
max-height: calc(100dvh - 88px);
|
||||
padding: 20px 22px 18px;
|
||||
}
|
||||
|
||||
.auth-page .login-logo {
|
||||
width: 58px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.auth-page .auth-card.login-card h1 {
|
||||
margin-bottom: 14px;
|
||||
font-size: 23px;
|
||||
}
|
||||
|
||||
.auth-page .login-card .auth-form {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.auth-page .login-field-label {
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.auth-page .login-field input,
|
||||
.auth-page .login-field select {
|
||||
min-height: 46px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.auth-page .login-card .primary-button {
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.auth-page .login-card .error-text {
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.auth-page .login-divider {
|
||||
margin: 12px 0 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.auth-page .login-footer {
|
||||
margin-top: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 520px) and (orientation: landscape) {
|
||||
.portal-page {
|
||||
display: block;
|
||||
min-height: 100dvh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.auth-page {
|
||||
min-height: 100dvh;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 52px 16px 6px;
|
||||
}
|
||||
|
||||
.auth-page .portal-panel {
|
||||
width: min(480px, 100%);
|
||||
}
|
||||
|
||||
.auth-page .auth-card.login-card {
|
||||
max-height: calc(100dvh - 64px);
|
||||
padding: 12px 22px 10px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.auth-page .login-logo {
|
||||
width: 34px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.auth-page .auth-card.login-card h1 {
|
||||
margin-bottom: 4px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.auth-page .login-card .auth-form {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.auth-page .login-field-label {
|
||||
margin-bottom: 2px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.auth-page .login-field input,
|
||||
.auth-page .login-field select {
|
||||
min-height: 44px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.auth-page .login-card .error-text {
|
||||
min-height: 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.auth-page .login-card .primary-button {
|
||||
min-height: 44px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.auth-page .login-divider {
|
||||
margin: 6px 0 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.auth-page .login-footer {
|
||||
margin-top: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.portal-toolbar {
|
||||
top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||
import { buildFrontendHandoffUrl, login, withNext } from '@/lib/auth-client';
|
||||
@ -20,6 +20,7 @@ export default function LoginPage() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const errorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
@ -30,7 +31,14 @@ export default function LoginPage() {
|
||||
const response = await login(username, password);
|
||||
window.location.replace(buildFrontendHandoffUrl(response, nextPath));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : pickPortalText(locale, '登录失败,请稍后重试', 'Sign-in failed. Please try again.'));
|
||||
const rawMessage = err instanceof Error ? err.message : '';
|
||||
const friendlyMessage = /401|Invalid credentials|用户名或密码/.test(rawMessage)
|
||||
? pickPortalText(locale, '用户名或密码错误,请检查后重试。', 'Username or password is incorrect. Please check and try again.')
|
||||
: pickPortalText(locale, '登录失败,请稍后重试。', 'Sign-in failed. Please try again.');
|
||||
setError(friendlyMessage);
|
||||
window.requestAnimationFrame(() => {
|
||||
errorRef.current?.focus();
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -56,7 +64,7 @@ export default function LoginPage() {
|
||||
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<div className="field login-field">
|
||||
<label className="visually-hidden" htmlFor="username">{pickPortalText(locale, '用户名', 'Username')}</label>
|
||||
<label className="login-field-label" htmlFor="username">{pickPortalText(locale, '用户名', 'Username')}</label>
|
||||
<UserIcon />
|
||||
<input
|
||||
id="username"
|
||||
@ -69,7 +77,7 @@ export default function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div className="field login-field">
|
||||
<label className="visually-hidden" htmlFor="password">{pickPortalText(locale, '密码', 'Password')}</label>
|
||||
<label className="login-field-label" htmlFor="password">{pickPortalText(locale, '密码', 'Password')}</label>
|
||||
<LockIcon />
|
||||
<input
|
||||
id="password"
|
||||
@ -90,9 +98,24 @@ export default function LoginPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="error-text">{error}</div>
|
||||
<div
|
||||
ref={errorRef}
|
||||
className="error-text"
|
||||
role={error ? 'alert' : undefined}
|
||||
aria-live="polite"
|
||||
tabIndex={error ? -1 : undefined}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
|
||||
<button className="primary-button" type="submit" disabled={loading}>
|
||||
<button
|
||||
className="primary-button"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
aria-label={loading
|
||||
? pickPortalText(locale, '登录中', 'Signing in')
|
||||
: pickPortalText(locale, '登录', 'Sign in')}
|
||||
>
|
||||
{loading
|
||||
? pickPortalText(locale, '登录中...', 'Signing in...')
|
||||
: <ArrowRightIcon />}
|
||||
|
||||
4
auth-portal/src/test-results/.last-run.json
Normal file
4
auth-portal/src/test-results/.last-run.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
@ -7,3 +7,12 @@ AUTHZ_UPSTREAM_TIMEOUT_SECONDS=15
|
||||
|
||||
DEPLOY_API_BASE_URL=http://beaver-deploy-control:8090
|
||||
DEPLOY_API_TOKEN=change-me
|
||||
|
||||
# User file system MinIO provisioning.
|
||||
USER_FILES_MINIO_PROVISIONING_ENABLED=1
|
||||
USER_FILES_MINIO_ENDPOINT=minio:9000
|
||||
USER_FILES_MINIO_PUBLIC_ENDPOINT=minio:9000
|
||||
USER_FILES_MINIO_ADMIN_ACCESS_KEY=change-me
|
||||
USER_FILES_MINIO_ADMIN_SECRET_KEY=change-me
|
||||
USER_FILES_MINIO_BUCKET=beaver-user-files
|
||||
USER_FILES_MINIO_SECURE=0
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
{
|
||||
"credentials": []
|
||||
"credentials": {}
|
||||
}
|
||||
|
||||
@ -16,6 +16,11 @@ APP_INSTANCE_API_BASE=
|
||||
DEFAULT_AUTHZ_BASE_URL=http://beaver-authz-service:19090
|
||||
DEFAULT_AUTHZ_OUTLOOK_MCP_URL=
|
||||
DEFAULT_OUTLOOK_MCP_SERVER_ID=outlook_mcp
|
||||
DEFAULT_USER_FILES_MAX_UPLOAD_BYTES=5368709120
|
||||
DEFAULT_EXTERNAL_CONNECTOR_BASE_URL=http://external-connector:8787
|
||||
DEFAULT_EXTERNAL_CONNECTOR_TOKEN=
|
||||
DEFAULT_BEAVER_BRIDGE_TOKEN=
|
||||
DEFAULT_INITIAL_SKILLS_DIR=/home/ivan/xuan/beaver_project/skills
|
||||
|
||||
DEPLOY_PUBLIC_SCHEME=http
|
||||
DEPLOY_PUBLIC_BASE_DOMAIN=localhost
|
||||
|
||||
@ -60,8 +60,9 @@ uv run server.py
|
||||
- Docker socket:`/var/run/docker.sock`
|
||||
- `/home/ivan/xuan/beaver_project/app-instance`
|
||||
- `/home/ivan/xuan/beaver_project/router-proxy`
|
||||
- `/home/ivan/xuan/beaver_project/skills`
|
||||
|
||||
并传入对应环境变量,让容器内脚本路径仍能访问这两个目录。
|
||||
并传入对应环境变量,让容器内脚本路径仍能访问这些目录。
|
||||
|
||||
关键点:
|
||||
|
||||
@ -79,14 +80,16 @@ docker run -d \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /home/ivan/xuan/beaver_project/app-instance:/home/ivan/xuan/beaver_project/app-instance \
|
||||
-v /home/ivan/xuan/beaver_project/router-proxy:/home/ivan/xuan/beaver_project/router-proxy \
|
||||
-v /home/ivan/xuan/beaver_project/skills:/home/ivan/xuan/beaver_project/skills:ro \
|
||||
-e APP_INSTANCE_DIR=/home/ivan/xuan/beaver_project/app-instance \
|
||||
-e ROUTER_PROXY_DIR=/home/ivan/xuan/beaver_project/router-proxy \
|
||||
-e DEFAULT_INITIAL_SKILLS_DIR=/home/ivan/xuan/beaver_project/skills \
|
||||
-e DEPLOY_CONTROL_API_TOKEN=change-me \
|
||||
-e APP_INSTANCE_IMAGE=beaver/app-instance:latest \
|
||||
-e APP_INSTANCE_NETWORK_NAME=beaver-instance-edge \
|
||||
beaver/deploy-control:latest
|
||||
```
|
||||
|
||||
如果这里错把宿主机目录映射成容器内的另一个短路径,例如 `/app-instance`,那么 `deploy-control` 通过 Docker socket 创建实例时会把错误路径传给 Docker,最终导致实例容器拿不到 `config.json` 并持续重启。
|
||||
如果这里错把宿主机目录映射成容器内的另一个短路径,例如 `/app-instance`,那么 `deploy-control` 通过 Docker socket 创建实例时会把错误路径传给 Docker,最终导致实例容器拿不到 `config.json` 并持续重启。`skills` 目录也必须挂载到同一个绝对路径;否则新实例第一次创建时不会在 workspace 里种入初始 skills。
|
||||
|
||||
新实例注册时不会写入模型 provider/API key。注册后由 `auth-portal` 引导页调用 `POST /api/instances/configure-provider`,在用户确认后写入该实例配置并重启实例容器。
|
||||
|
||||
@ -42,6 +42,13 @@ DEFAULT_AUTHZ_INTERNAL_TOKEN = os.environ.get("DEFAULT_AUTHZ_INTERNAL_TOKEN", ""
|
||||
DEFAULT_AUTHZ_OUTLOOK_MCP_URL = os.environ.get("DEFAULT_AUTHZ_OUTLOOK_MCP_URL", "").strip()
|
||||
DEFAULT_OUTLOOK_MCP_SERVER_ID = os.environ.get("DEFAULT_OUTLOOK_MCP_SERVER_ID", "outlook_mcp").strip() or "outlook_mcp"
|
||||
DEFAULT_USER_FILES_MAX_UPLOAD_BYTES = os.environ.get("DEFAULT_USER_FILES_MAX_UPLOAD_BYTES", "").strip()
|
||||
DEFAULT_EXTERNAL_CONNECTOR_BASE_URL = os.environ.get(
|
||||
"DEFAULT_EXTERNAL_CONNECTOR_BASE_URL",
|
||||
"http://external-connector:8787",
|
||||
).strip()
|
||||
DEFAULT_EXTERNAL_CONNECTOR_TOKEN = os.environ.get("DEFAULT_EXTERNAL_CONNECTOR_TOKEN", "").strip()
|
||||
DEFAULT_BEAVER_BRIDGE_TOKEN = os.environ.get("DEFAULT_BEAVER_BRIDGE_TOKEN", "").strip()
|
||||
DEFAULT_INITIAL_SKILLS_DIR = os.environ.get("DEFAULT_INITIAL_SKILLS_DIR", str(APP_INSTANCE_DIR.parent / "skills")).strip()
|
||||
PUBLIC_SCHEME = os.environ.get("DEPLOY_PUBLIC_SCHEME", "http").strip() or "http"
|
||||
PUBLIC_BASE_DOMAIN = os.environ.get("DEPLOY_PUBLIC_BASE_DOMAIN", "localhost").strip()
|
||||
PUBLIC_HOST_TEMPLATE = os.environ.get("DEPLOY_PUBLIC_HOST_TEMPLATE", "{slug}.{base_domain}").strip()
|
||||
@ -274,6 +281,14 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
command.extend(["--outlook-mcp-server-id", DEFAULT_OUTLOOK_MCP_SERVER_ID])
|
||||
if DEFAULT_USER_FILES_MAX_UPLOAD_BYTES:
|
||||
command.extend(["--user-files-max-upload-bytes", DEFAULT_USER_FILES_MAX_UPLOAD_BYTES])
|
||||
if DEFAULT_EXTERNAL_CONNECTOR_BASE_URL:
|
||||
command.extend(["--external-connector-base-url", DEFAULT_EXTERNAL_CONNECTOR_BASE_URL])
|
||||
if DEFAULT_EXTERNAL_CONNECTOR_TOKEN:
|
||||
command.extend(["--external-connector-token", DEFAULT_EXTERNAL_CONNECTOR_TOKEN])
|
||||
if DEFAULT_BEAVER_BRIDGE_TOKEN:
|
||||
command.extend(["--bridge-token", DEFAULT_BEAVER_BRIDGE_TOKEN])
|
||||
if DEFAULT_INITIAL_SKILLS_DIR:
|
||||
command.extend(["--initial-skills-dir", DEFAULT_INITIAL_SKILLS_DIR])
|
||||
if payload.get("replace") is True:
|
||||
command.append("--replace")
|
||||
|
||||
|
||||
58
deploy-control/tests/test_connector_instance_config.py
Normal file
58
deploy-control/tests/test_connector_instance_config.py
Normal file
@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SERVER_PATH = Path(__file__).resolve().parents[1] / "server.py"
|
||||
|
||||
|
||||
def _load_server_module():
|
||||
spec = importlib.util.spec_from_file_location("deploy_control_server_connector_tests", SERVER_PATH)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_new_instance_receives_external_connector_configuration(monkeypatch) -> None:
|
||||
server = _load_server_module()
|
||||
commands: list[list[str]] = []
|
||||
record: dict[str, Any] = {
|
||||
"instance_id": "terminaltest",
|
||||
"container_name": "app-instance-terminaltest",
|
||||
"host_port": 20001,
|
||||
"public_url": "http://terminaltest.example.test",
|
||||
}
|
||||
lookups = iter([None, None, record])
|
||||
|
||||
monkeypatch.setattr(server, "get_registry_record", lambda **_kwargs: next(lookups))
|
||||
monkeypatch.setattr(server, "ensure_network", lambda: None)
|
||||
monkeypatch.setattr(server, "ensure_proxy", lambda: None)
|
||||
monkeypatch.setattr(server, "wait_for_backend", lambda _record: None)
|
||||
monkeypatch.setattr(server, "DEFAULT_EXTERNAL_CONNECTOR_BASE_URL", "http://external-connector:8787")
|
||||
monkeypatch.setattr(server, "DEFAULT_EXTERNAL_CONNECTOR_TOKEN", "connector-token")
|
||||
monkeypatch.setattr(server, "DEFAULT_BEAVER_BRIDGE_TOKEN", "bridge-token")
|
||||
monkeypatch.setattr(server, "DEFAULT_INITIAL_SKILLS_DIR", "/srv/beaver/skills")
|
||||
|
||||
def capture_command(args: list[str], **_kwargs: Any) -> str:
|
||||
commands.append(args)
|
||||
return ""
|
||||
|
||||
monkeypatch.setattr(server, "run_command", capture_command)
|
||||
|
||||
result = server.create_or_get_instance(
|
||||
{
|
||||
"username": "terminaltest",
|
||||
"password": "secret",
|
||||
"instance_id": "terminaltest",
|
||||
}
|
||||
)
|
||||
|
||||
command = commands[0]
|
||||
assert command[command.index("--external-connector-base-url") + 1] == "http://external-connector:8787"
|
||||
assert command[command.index("--external-connector-token") + 1] == "connector-token"
|
||||
assert command[command.index("--bridge-token") + 1] == "bridge-token"
|
||||
assert command[command.index("--initial-skills-dir") + 1] == "/srv/beaver/skills"
|
||||
assert result["created"] is True
|
||||
@ -5,11 +5,11 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
BEAVER_BRIDGE_BASE_URL: ${BEAVER_BRIDGE_BASE_URL:-http://app-instance:8080}
|
||||
BEAVER_BRIDGE_TOKEN: ${BEAVER_BRIDGE_TOKEN}
|
||||
CONNECTOR_API_TOKEN: ${EXTERNAL_CONNECTOR_TOKEN}
|
||||
BEAVER_BRIDGE_TOKEN: ${BEAVER_BRIDGE_TOKEN:?BEAVER_BRIDGE_TOKEN is required}
|
||||
CONNECTOR_API_TOKEN: ${EXTERNAL_CONNECTOR_TOKEN:?EXTERNAL_CONNECTOR_TOKEN is required}
|
||||
CONNECTOR_HOME: /var/lib/external-connector
|
||||
CONNECTOR_PUBLIC_BASE_URL: ${CONNECTOR_PUBLIC_BASE_URL:-http://localhost:8787}
|
||||
CONNECTOR_PROVIDER: ${CONNECTOR_PROVIDER:-vendor_cli}
|
||||
CONNECTOR_PROVIDER: ${CONNECTOR_PROVIDER:-official}
|
||||
CONNECTOR_COMMAND_TIMEOUT_SECONDS: ${CONNECTOR_COMMAND_TIMEOUT_SECONDS:-120}
|
||||
WEIXIN_CONNECT_COMMAND: ${WEIXIN_CONNECT_COMMAND:-}
|
||||
WEIXIN_STATUS_COMMAND: ${WEIXIN_STATUS_COMMAND:-}
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
# Auto-Accept on New Topic Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Silently accept an awaiting Task before processing an unrelated new topic.
|
||||
|
||||
**Architecture:** Keep the existing Intent Agent actions. Treat `simple_chat` and `new_task` decisions made while a Task is active as new-topic boundaries, reuse `submit_acceptance()` for the old Task's latest run, and then continue the original routing decision.
|
||||
|
||||
**Tech Stack:** Python, pytest, Beaver TaskService and AgentService
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Lock the State Transition with Tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `app-instance/backend/tests/unit/test_task_mode_feedback.py`
|
||||
|
||||
- [ ] Add a failing test proving an unrelated `simple_chat` message formally accepts the previous Task and does not append another run to it.
|
||||
- [ ] Add a failing test proving `new_task` formally accepts the previous Task before creating a separate Task.
|
||||
- [ ] Add tests proving `continue_task` and `revise_task` retain the existing active Task behavior.
|
||||
- [ ] Run:
|
||||
|
||||
```bash
|
||||
uv run pytest -q tests/unit/test_task_mode_feedback.py
|
||||
```
|
||||
|
||||
Expected before implementation: the new-topic tests fail because the previous Task remains `awaiting_acceptance`.
|
||||
|
||||
### Task 2: Implement New-Topic Auto-Accept
|
||||
|
||||
**Files:**
|
||||
- Modify: `app-instance/backend/beaver/services/agent_service.py`
|
||||
|
||||
- [ ] Add a focused async helper that accepts only an `awaiting_acceptance` Task with a latest run.
|
||||
- [ ] Call the helper after routing when the decision is `simple_chat` or starts a new Task.
|
||||
- [ ] Reuse `submit_acceptance()` so acceptance history, final accepted run, run memory, and learning behavior remain consistent.
|
||||
- [ ] Run:
|
||||
|
||||
```bash
|
||||
uv run pytest -q tests/unit/test_task_mode_feedback.py
|
||||
```
|
||||
|
||||
Expected: all task-mode feedback tests pass.
|
||||
|
||||
### Task 3: Clarify Intent Routing Guidance
|
||||
|
||||
**Files:**
|
||||
- Modify: `app-instance/backend/beaver/tasks/router.py`
|
||||
- Modify: `app-instance/backend/beaver/skills/builtin/intent-agent-router/SKILL.md`
|
||||
- Modify: `app-instance/backend/tests/unit/test_main_agent_router.py`
|
||||
|
||||
- [ ] Assert the generated routing prompt explicitly says unrelated lightweight conversation is `simple_chat`, not `revise_task`.
|
||||
- [ ] Update both routing guidance sources with the same rule and examples.
|
||||
- [ ] Run:
|
||||
|
||||
```bash
|
||||
uv run pytest -q tests/unit/test_main_agent_router.py
|
||||
```
|
||||
|
||||
Expected: all router tests pass.
|
||||
|
||||
### Task 4: Regression Verification
|
||||
|
||||
**Files:**
|
||||
- Verify only
|
||||
|
||||
- [ ] Run:
|
||||
|
||||
```bash
|
||||
uv run pytest -q tests/unit/test_main_agent_router.py tests/unit/test_task_mode_feedback.py tests/unit/test_active_task_api.py tests/unit/test_process_projection.py
|
||||
```
|
||||
|
||||
- [ ] Inspect the final diff to confirm no frontend confirmation or unrelated state changes were introduced.
|
||||
@ -0,0 +1,75 @@
|
||||
# Chat Task Timeline Consistency Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Render the active Task's canonical timeline in the chat progress sidebar and hide it when no active Task exists.
|
||||
|
||||
**Architecture:** Extract task-scoped process filtering into a shared frontend helper, use it in both Task detail and chat, and make the chat sidebar a responsive wrapper around the existing `TaskTimeline` component.
|
||||
|
||||
**Tech Stack:** React, Next.js, TypeScript, Vitest
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extract Shared Task Process Selection
|
||||
|
||||
**Files:**
|
||||
- Create: `app-instance/frontend/lib/task-process.ts`
|
||||
- Create: `app-instance/frontend/lib/task-process.test.ts`
|
||||
- Modify: `app-instance/frontend/app/(app)/tasks/[taskId]/page.tsx`
|
||||
|
||||
- [ ] Write failing tests for merging persisted task process data with matching live process data.
|
||||
- [ ] Implement `selectTaskProcess()` returning task-scoped runs, events, and artifacts.
|
||||
- [ ] Replace the Task detail page's local filtering with the shared helper.
|
||||
- [ ] Run:
|
||||
|
||||
```bash
|
||||
npm test -- --run lib/task-process.test.ts lib/task-timeline.test.ts
|
||||
```
|
||||
|
||||
### Task 2: Replace Chat Progress View with Task Timeline
|
||||
|
||||
**Files:**
|
||||
- Modify: `app-instance/frontend/components/chat-workbench/CurrentSessionProgressSidebar.tsx`
|
||||
- Modify: `app-instance/frontend/app/(app)/page.tsx`
|
||||
|
||||
- [ ] Load full `BackendTask` detail whenever `activeTask` exists.
|
||||
- [ ] Clear full Task detail whenever active Task becomes `null` or the session changes.
|
||||
- [ ] Build chat timeline cards using `selectTaskProcess()` and `buildTaskTimelineCards()`.
|
||||
- [ ] Change `CurrentSessionProgressSidebar` to accept timeline cards and render `TaskTimeline` without acceptance controls.
|
||||
- [ ] Remove the chat page's use of `buildSessionProgressView()`.
|
||||
|
||||
### Task 3: Add Visibility and Consistency Tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `app-instance/frontend/lib/task-process.test.ts`
|
||||
- Modify: `app-instance/frontend/lib/task-timeline.test.ts`
|
||||
- Delete if unused: `app-instance/frontend/lib/session-progress.test.ts`
|
||||
- Delete if unused: `app-instance/frontend/lib/session-progress.ts`
|
||||
|
||||
- [ ] Cover empty/no-active input behavior in the shared helper.
|
||||
- [ ] Confirm the same Task/process input creates the same timeline cards on both surfaces.
|
||||
- [ ] Remove the obsolete session-progress builder and tests if no imports remain.
|
||||
- [ ] Run:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
### Task 4: Frontend Verification
|
||||
|
||||
**Files:**
|
||||
- Verify only
|
||||
|
||||
- [ ] Run:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run build
|
||||
```
|
||||
|
||||
- [ ] Validate the rendered chat flow with Playwright because the Browser plugin is not available:
|
||||
|
||||
```text
|
||||
chat page with active Task -> open current-session progress -> same timeline cards as Task detail
|
||||
Task closes -> current-session progress disappears
|
||||
```
|
||||
104
docs/superpowers/plans/2026-06-04-initial-multi-search-engine.md
Normal file
104
docs/superpowers/plans/2026-06-04-initial-multi-search-engine.md
Normal file
@ -0,0 +1,104 @@
|
||||
# Initial Multi Search Engine Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the initial `web-operation` skill with SkillHub `multi-search-engine` while keeping `web_fetch` reliably available when the skill is selected.
|
||||
|
||||
**Architecture:** Initial skills are copied from the repository `skills/` directory into each instance workspace by `create-instance.sh` and `entrypoint.sh`. This change updates the seed catalog, not existing user workspace state.
|
||||
|
||||
**Tech Stack:** Python skill catalog storage, JSON seed metadata, Markdown `SKILL.md`, pytest.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Update Initial Skill Contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `app-instance/backend/tests/unit/test_initial_skill_tool_hints.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Change `EXPECTED_INITIAL_SKILL_TOOLS` so it expects:
|
||||
|
||||
```python
|
||||
"multi-search-engine": ["web_fetch"],
|
||||
```
|
||||
|
||||
and no longer expects:
|
||||
|
||||
```python
|
||||
"web-operation": ["web_fetch", "web_search"],
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd app-instance/backend
|
||||
pytest tests/unit/test_initial_skill_tool_hints.py -q
|
||||
```
|
||||
|
||||
Expected: FAIL because `skills/multi-search-engine/versions/v0001/SKILL.md` does not exist yet.
|
||||
|
||||
### Task 2: Replace Seed Skill
|
||||
|
||||
**Files:**
|
||||
- Create: `skills/multi-search-engine/current.json`
|
||||
- Create: `skills/multi-search-engine/skill.json`
|
||||
- Create: `skills/multi-search-engine/versions/v0001/SKILL.md`
|
||||
- Create: `skills/multi-search-engine/versions/v0001/version.json`
|
||||
- Create: `skills/multi-search-engine/versions/v0001/CHANGELOG.md`
|
||||
- Create: `skills/multi-search-engine/versions/v0001/CHANNELLOG.md`
|
||||
- Create: `skills/multi-search-engine/versions/v0001/config.json`
|
||||
- Create: `skills/multi-search-engine/versions/v0001/metadata.json`
|
||||
- Create: `skills/multi-search-engine/versions/v0001/references/advanced-search.md`
|
||||
- Create: `skills/multi-search-engine/versions/v0001/references/international-search.md`
|
||||
- Modify: `skills/_index/published.json`
|
||||
|
||||
- [ ] **Step 1: Add SkillHub content**
|
||||
|
||||
Fetch `global/multi-search-engine@20260413.065325` from SkillHub and store it as seed version `v0001`.
|
||||
|
||||
- [ ] **Step 2: Add tool hint**
|
||||
|
||||
Ensure `SKILL.md` frontmatter contains:
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
- web_fetch
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update published index**
|
||||
|
||||
Remove `web-operation` and add `multi-search-engine`.
|
||||
|
||||
### Task 3: Verify
|
||||
|
||||
**Files:**
|
||||
- Test: `app-instance/backend/tests/unit/test_initial_skill_tool_hints.py`
|
||||
|
||||
- [ ] **Step 1: Run targeted tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd app-instance/backend
|
||||
pytest tests/unit/test_initial_skill_tool_hints.py tests/unit/test_marketplace_and_mcp.py -q
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Inspect seed metadata**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
print(json.loads(Path('skills/_index/published.json').read_text())['items'])
|
||||
print(json.loads(Path('skills/multi-search-engine/versions/v0001/version.json').read_text())['tool_hints'])
|
||||
PY
|
||||
```
|
||||
|
||||
Expected: `multi-search-engine` is published, `web-operation` is absent, and tool hints are `["web_fetch"]`.
|
||||
@ -0,0 +1,60 @@
|
||||
# Auto-Accept Task When a New Topic Starts
|
||||
|
||||
## Goal
|
||||
|
||||
Prevent unrelated follow-up conversation from being appended to the previous
|
||||
Task. When the Intent Agent decides that the user's current message starts a
|
||||
new topic, Beaver should silently accept the previous Task before processing
|
||||
the current message.
|
||||
|
||||
## User Experience
|
||||
|
||||
- No confirmation dialog or extra assistant message is shown.
|
||||
- A related follow-up or requested change continues the existing Task.
|
||||
- An unrelated lightweight message is handled as `simple_chat`.
|
||||
- Unrelated work that needs Task capabilities is handled as `new_task`.
|
||||
- Before either new-topic path continues, the previous Task is formally
|
||||
accepted.
|
||||
|
||||
## Routing Rules
|
||||
|
||||
The existing Intent Agent actions remain unchanged:
|
||||
|
||||
- `continue_task` and `revise_task` belong to the active Task.
|
||||
- `close_task` and `abandon_task` keep their existing explicit semantics.
|
||||
- With an active Task, `simple_chat` means an unrelated lightweight new topic.
|
||||
- With an active Task, `new_task` means unrelated work that needs a separate
|
||||
Task.
|
||||
|
||||
The Intent Agent guidance must explicitly distinguish unrelated lightweight
|
||||
conversation from revisions. A message must not be classified as
|
||||
`revise_task` merely because an active Task is awaiting acceptance.
|
||||
|
||||
## State Transition
|
||||
|
||||
Before processing a `simple_chat` or `new_task` decision:
|
||||
|
||||
1. Check whether the active Task is `awaiting_acceptance`.
|
||||
2. Find its latest completed run.
|
||||
3. Record a normal `accept` acceptance against that run.
|
||||
4. Continue processing the current message using the original routing
|
||||
decision.
|
||||
|
||||
The normal acceptance path must be reused so that the Task becomes `closed`,
|
||||
`final_accepted_run_id` is recorded, acceptance events are persisted, run
|
||||
memory is updated, and skill-learning candidates can be generated.
|
||||
|
||||
Tasks without an acceptance-eligible completed run are left unchanged. Router
|
||||
failures retain the existing conservative `continue_task` fallback and must
|
||||
not auto-accept a Task.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend tests must cover:
|
||||
|
||||
- An unrelated `simple_chat` message accepts the previous Task and is not
|
||||
appended as another Task run.
|
||||
- A `new_task` decision accepts the previous Task and creates a separate Task.
|
||||
- `continue_task` and `revise_task` do not auto-accept the active Task.
|
||||
- Router failure fallback does not auto-accept the active Task.
|
||||
- Auto-accept records the final accepted run and normal acceptance history.
|
||||
@ -0,0 +1,59 @@
|
||||
# Chat Current-Task Timeline Consistency
|
||||
|
||||
## Goal
|
||||
|
||||
Make the chat page's current-session progress panel show the same timeline
|
||||
content as the active Task's detail page.
|
||||
|
||||
## Visibility
|
||||
|
||||
- Show the chat-side timeline only while the current session has an active
|
||||
Task.
|
||||
- Hide the panel immediately when the Task is accepted, auto-accepted,
|
||||
abandoned, closed, or when the user switches sessions.
|
||||
- Do not show the most recently completed Task after it is no longer active.
|
||||
|
||||
## Shared Data Model
|
||||
|
||||
The Task detail page remains the canonical timeline behavior.
|
||||
|
||||
Both surfaces must:
|
||||
|
||||
1. Load the full `BackendTask` payload from `/api/tasks/{task_id}`.
|
||||
2. Combine the task's persisted process data with matching live process data.
|
||||
3. Use one shared task-process filtering helper.
|
||||
4. Build cards with `buildTaskTimelineCards()`.
|
||||
5. Render cards with `TaskTimeline`.
|
||||
|
||||
This keeps card types, ordering, fallback milestones, result history,
|
||||
acceptance history, tool status, and deduplication consistent.
|
||||
|
||||
## Chat Panel
|
||||
|
||||
`CurrentSessionProgressSidebar` becomes a responsive wrapper around
|
||||
`TaskTimeline`.
|
||||
|
||||
- Desktop keeps the existing right sidebar.
|
||||
- Smaller viewports keep the existing floating open button and drawer.
|
||||
- The panel title remains "当前会话的运行进度".
|
||||
- Timeline cards match the Task detail timeline.
|
||||
- Chat does not render duplicate acceptance controls inside the sidebar,
|
||||
because acceptance controls already exist on chat result messages.
|
||||
|
||||
## Data Refresh
|
||||
|
||||
- Whenever the active Task changes, the chat page loads its full Task detail.
|
||||
- Existing message, process, feedback, and WebSocket refresh paths reload both
|
||||
the active Task identity and its full detail.
|
||||
- If the active-task endpoint returns `null`, the cached active Task detail is
|
||||
cleared immediately and the sidebar disappears.
|
||||
- A task-detail load failure hides the sidebar rather than showing stale data.
|
||||
|
||||
## Testing
|
||||
|
||||
- Shared process filtering returns the same task-scoped runs, events, and
|
||||
artifacts for both surfaces.
|
||||
- The chat-side timeline cards are produced by `buildTaskTimelineCards()`.
|
||||
- No active Task produces no chat-side timeline.
|
||||
- Switching to a closed/no-active Task clears the chat-side timeline.
|
||||
- Frontend unit tests, typecheck, and production build pass.
|
||||
84
docs/ui-ux/README.md
Normal file
84
docs/ui-ux/README.md
Normal file
@ -0,0 +1,84 @@
|
||||
# Beaver UI/UX 页面维护文档
|
||||
|
||||
## 文档目的
|
||||
|
||||
本目录用于按页面持续维护 Beaver 前端的 UI/UX 结构与真实测试结论。
|
||||
|
||||
每一页需要记录:
|
||||
|
||||
- 页面、子页、详情页、弹窗和主要状态。
|
||||
- 组件层级关系、视觉位置和响应式变化。
|
||||
- 每个可操作控件的触发方式、状态变化、反馈、UX 目的和异常恢复逻辑。
|
||||
- 多视口下的越界、重叠、滚动、触控目标和可访问性结论。
|
||||
- 已发现问题、优先级、代码位置和后续验收标准。
|
||||
|
||||
文档描述的是当前真实实现。建议设计与当前实现不一致时,必须明确标记为“待修复”,不能混写为已实现行为。
|
||||
|
||||
## 测试原则
|
||||
|
||||
每个页面至少执行以下检查:
|
||||
|
||||
1. 页面身份、标题和首屏内容正确,无空白页或框架错误覆盖层。
|
||||
2. 在 `320px`、`375px`、`390px`、`768px`、`1024px`、`1365px` 和宽屏视口检查布局。
|
||||
3. 至少检查一个手机横屏视口。
|
||||
4. 检查横向越界、内容裁切、组件重叠、嵌套滚动和固定元素遮挡。
|
||||
5. 真实点击或键盘操作每个主要控件,并验证状态变化。
|
||||
6. 检查加载、成功、失败、空数据、禁用和权限不足状态。
|
||||
7. 检查键盘顺序、焦点可见性、可访问名称、错误播报和触控目标尺寸。
|
||||
8. 截图和临时测试脚本默认保存在 `/tmp`,不提交到仓库。
|
||||
|
||||
## 问题等级
|
||||
|
||||
| 等级 | 定义 |
|
||||
| --- | --- |
|
||||
| P0 | 阻断核心流程、页面不可用、数据安全或严重误操作风险 |
|
||||
| P1 | 核心流程明显受损、响应式严重异常、关键可访问性缺失 |
|
||||
| P2 | 可完成任务但体验、可发现性、触控或反馈质量不足 |
|
||||
| P3 | 视觉一致性、轻微布局或低风险优化项 |
|
||||
|
||||
## 页面清单
|
||||
|
||||
| 页面域 | 页面或状态 | 路由 | 文档 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 认证门户 | 登录页 | `/login?next=...` | [登录页](./pages/auth-login.md) | 已修复并复测通过 |
|
||||
| 认证门户 | 注册页 | `/register?next=...` | 待创建 | 待测试 |
|
||||
| 认证门户 | 注册后的模型配置子页 | `/register?next=...` 注册成功后 | 待创建 | 待测试 |
|
||||
| 主应用认证入口 | 登录跳转页 | `/login?next=...` | 待创建 | 待测试 |
|
||||
| 主应用 | 对话工作台 | `/` | [对话工作台](./pages/chat-workbench.md) | 已修复并复测通过 |
|
||||
| 主应用 | 任务列表 | `/tasks` | [Task 任务页](./pages/task-management.md) | 已修复并复测通过 |
|
||||
| 主应用 | 任务详情 | `/tasks/[taskId]` | [Task 任务页](./pages/task-management.md) | 已修复并复测通过 |
|
||||
| 主应用 | 通知列表 | `/notifications` | [通知页](./pages/notifications.md) | 已修复并本地复测通过 |
|
||||
| 主应用 | 通知详情 | `/notifications/[scheduledRunId]` | [通知页](./pages/notifications.md) | 已修复并本地复测通过 |
|
||||
| 主应用 | 定时任务 | `/cron` | 待创建 | 待测试 |
|
||||
| 主应用 | 技能列表与详情 | `/skills` | [技能页](./pages/skills.md) | 已修复并复测通过 |
|
||||
| 主应用 | 文件管理与预览 | `/files` | [文件页](./pages/files.md) | 已修复并复测通过 |
|
||||
| 主应用 | MCP 工具管理 | `/mcp` | [工具页](./pages/mcp-tools.md) | 已修复并复测通过 |
|
||||
| 主应用 | 智能体管理 | `/agents` | 待创建 | 待测试 |
|
||||
| 主应用 | Outlook | `/outlook` | [Outlook 页](./pages/outlook.md) | 已修复并复测通过 |
|
||||
| 主应用 | 技能市场与详情 | `/marketplace` | [市场页](./pages/marketplace.md) | 已修复并复测通过 |
|
||||
| 主应用 | 配置 | `/settings` | [配置页](./pages/settings.md) | 已修复并复测通过 |
|
||||
| 主应用 | 系统状态 | `/status` | [配置页](./pages/settings.md) | 与配置页同实现,已复测通过 |
|
||||
| 主应用 | 日志 | `/logs` | 待创建 | 待测试 |
|
||||
|
||||
## 当前测试环境
|
||||
|
||||
- 浏览器自动化:Playwright Chromium。
|
||||
- Browser 插件:本轮不可用,使用 Playwright fallback。
|
||||
- 登录页测试日期:2026-06-04。
|
||||
- 登录页测试结果:自动化用例执行通过;所有实测视口无页面横向越界、无小点击目标、无首屏控件出界。
|
||||
- 对话工作台测试日期:2026-06-04。
|
||||
- 对话工作台测试结果:自动化用例 `4 passed`;使用模拟 API 数据完成真实浏览器点击、键盘、响应式测量和截图;所有实测视口无页面横向越界、无可见小点击目标。
|
||||
- Task 任务页测试日期:2026-06-04。
|
||||
- Task 任务页测试结果:自动化用例 `14 passed`;覆盖普通任务、定时任务、任务详情和缺省态;所有实测视口无页面横向越界、无可见小点击目标。
|
||||
- 通知页测试日期:2026-06-04。
|
||||
- 通知页测试结果:本地自动化用例 `4 passed`;覆盖通知列表、通知详情、空态、错误态和回复交互;所有实测视口无页面横向越界、无可见小点击目标。
|
||||
- 技能页测试日期:2026-06-04。
|
||||
- 技能页测试结果:本地与 `terminaltest` 自动化用例均 `3 passed`;覆盖候选操作、草稿评审操作和草稿页响应式;候选/草稿操作后不再回到已发布页,所有实测视口无页面横向越界、无可见小点击目标。
|
||||
- 文件页测试日期:2026-06-04。
|
||||
- 文件页测试结果:本地与 `terminaltest` 自动化用例均 `4 passed`;覆盖根目录空态、创建文件夹、目录切换、Markdown 预览、下载、删除、错误态和响应式;所有实测视口无页面横向越界、无可见小点击目标。
|
||||
- 工具页测试日期:2026-06-04。
|
||||
- 工具页测试结果:本地与 `terminaltest` 自动化用例均 `4 passed`;覆盖服务选择、工具详情、Tab 切换、Test、Add、Edit、Delete confirm、Refresh、JSON 错误、加载错误和响应式;所有实测视口无页面横向越界、无可见小点击目标。
|
||||
- Outlook 页测试日期:2026-06-04。
|
||||
- Outlook 页测试结果:本地与 `terminaltest` 自动化用例均 `4 passed`;覆盖已配置状态、overview 加载、Inbox、邮件详情、Calendar、日程详情、Settings、Test、Save、Disconnect confirm、状态错误和响应式;所有实测视口无页面横向越界、无可见小点击目标。
|
||||
- 市场页与配置页测试日期:2026-06-04。
|
||||
- 市场页与配置页测试结果:本地与 `terminaltest` 自动化用例均 `4 passed`;覆盖市场搜索、详情、文件预览、安装、配置页智能体保存、Provider 保存、Feishu 通道保存、重启确认、状态错误和响应式;所有实测视口无页面横向越界、无可见小点击目标。
|
||||
220
docs/ui-ux/pages/auth-login.md
Normal file
220
docs/ui-ux/pages/auth-login.md
Normal file
@ -0,0 +1,220 @@
|
||||
# 认证门户:登录页 UI/UX
|
||||
|
||||
## 1. 页面定义
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 页面名称 | 认证门户登录页 |
|
||||
| 真实页面路由 | `/login?next=<目标路径>` |
|
||||
| 页面实现 | `auth-portal/src/app/login/page.tsx` |
|
||||
| 样式实现 | `auth-portal/src/app/globals.css` |
|
||||
| API 客户端 | `auth-portal/src/lib/auth-client.ts` |
|
||||
| 主应用入口关系 | 主应用 `/login` 只显示跳转提示并重定向到认证门户,不承载登录表单 |
|
||||
| 核心任务 | 输入用户名和密码,完成认证后通过 handoff 返回目标工作区页面 |
|
||||
| 测试状态 | 已完成修复并复测通过;所有实测视口无横向越界、无小点击目标、无首屏控件出界 |
|
||||
|
||||
## 2. 信息架构与组件层级
|
||||
|
||||
```text
|
||||
portal-page
|
||||
├── portal-toolbar 层级 z-index: 10,右上角
|
||||
│ └── LanguageSwitcher
|
||||
│ ├── Lang 标签
|
||||
│ ├── ZH 按钮
|
||||
│ └── EN 按钮
|
||||
└── auth-page 全屏背景层
|
||||
└── portal-panel 登录卡片定位容器
|
||||
└── auth-card.login-card 登录主容器,手机竖屏与横屏均做紧凑适配
|
||||
├── Boardware Logo
|
||||
├── 页面标题
|
||||
├── auth-form
|
||||
│ ├── 用户名字段
|
||||
│ │ ├── 可见 label
|
||||
│ │ ├── 用户图标
|
||||
│ │ └── 用户名 input
|
||||
│ ├── 密码字段
|
||||
│ │ ├── 可见 label
|
||||
│ │ ├── 锁图标
|
||||
│ │ ├── 密码 input
|
||||
│ │ └── 显示或隐藏密码按钮
|
||||
│ ├── 错误信息区域
|
||||
│ └── 登录提交按钮
|
||||
├── “或”分隔线
|
||||
└── 注册引导与注册链接
|
||||
```
|
||||
|
||||
### 层级关系
|
||||
|
||||
- 背景图属于最低视觉层,用于传达产品品牌和 Agent/Memory/Tools 等能力氛围。
|
||||
- 登录卡片是唯一主任务容器,具有半透明白色表面、边框和阴影。
|
||||
- 语言切换器脱离登录卡片,绝对定位在页面右上角,`z-index: 10`。
|
||||
- 表单错误位于密码字段与登录按钮之间,属于当前提交动作的内联反馈。
|
||||
- 页面无弹窗、抽屉或二级详情层。
|
||||
|
||||
## 3. 布局与大概位置
|
||||
|
||||
### 桌面与宽屏,大于 920px
|
||||
|
||||
- 页面占满视口,背景图居中并 `cover`。
|
||||
- 登录卡片位于页面右侧,垂直居中。
|
||||
- `portal-panel` 宽度使用 `clamp(360px, 34vw, 560px)`。
|
||||
- 页面右侧留白由 `clamp(24px, 8vw, 128px)` 控制。
|
||||
- 语言切换器固定在右上角,距顶部约 `20px`,距右侧约 `24px`。
|
||||
- 实测 `1365×900` 和 `1920×1080` 无横向越界、无组件重叠。
|
||||
|
||||
### 平板与中等宽度,小于等于 920px
|
||||
|
||||
- 登录卡片从右侧布局切换为水平居中、靠近页面底部。
|
||||
- 容器最大宽度约 `520px`。
|
||||
- 页面顶部保留品牌背景展示空间。
|
||||
- 实测 `768×1024` 和 `1024×768` 无横向越界、无组件重叠。
|
||||
|
||||
### 手机,小于等于 640px
|
||||
|
||||
- 页面左右边距约 `16px`。
|
||||
- 登录卡片在矮屏手机下进入紧凑节奏,缩小 Logo、标题和表单间距。
|
||||
- 输入框高度约 `54px`,字号 `16px`,可避免 iOS 输入自动缩放。
|
||||
- `320×568`、`375×667`、`390×844` 均可在首屏完整展示主要内容。
|
||||
|
||||
### 手机横屏
|
||||
|
||||
- 实测 `844×390` 时使用横屏紧凑布局,主要控件完整位于视口内。
|
||||
- 页面无横向越界,无双层滚动。
|
||||
|
||||
## 4. 页面状态
|
||||
|
||||
| 状态 | 当前表现 | UX 目的 | 测试结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| 初始状态 | 显示 Logo、标题、空用户名和密码、登录按钮、注册入口 | 清晰建立品牌和唯一主任务 | 通过 |
|
||||
| 输入状态 | 输入框显示文本;密码默认掩码 | 防止密码旁观泄露 | 通过 |
|
||||
| 密码可见状态 | 点击眼睛按钮后,密码类型切换为 `text`,按钮名称同步变为“隐藏密码” | 降低密码输入错误 | 通过 |
|
||||
| 浏览器必填校验 | 空表单提交被浏览器阻止,并聚焦用户名 | 避免无效网络请求 | 通过 |
|
||||
| 提交中 | 登录按钮禁用,并显示“登录中...” | 防止重复提交,告知请求正在处理 | 通过 |
|
||||
| 登录失败 | 在密码字段下方显示友好错误,按钮恢复可用,错误区域可被播报并获得焦点 | 让用户修正凭据后重试 | 通过 |
|
||||
| 登录成功 | 构造 `/handoff?code=...&next=...` 并使用 `location.replace` 跳转 | 安全把认证结果交给目标工作区,并避免返回到已提交登录页 | 通过 |
|
||||
| 语言切换 | ZH/EN 立即更新字段、错误和辅助文案,并写入 cookie/localStorage | 支持中英文用户并保持选择 | 通过,刷新后保持 |
|
||||
| 注册跳转 | 跳转到 `/register` 并保留 `next` 参数 | 注册完成后仍返回原目标页 | 通过 |
|
||||
|
||||
## 5. 操作与 UX 逻辑
|
||||
|
||||
| 操作 | 触发方式 | 状态变化与反馈 | UX 目的 | 当前结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 切换中文或英文 | 点击右上角 ZH/EN | 当前语言按钮高亮;页面文案立即更新;刷新后保持 | 降低语言理解成本 | 正常 |
|
||||
| 聚焦用户名或密码 | 点击或 Tab | 输入框边框、背景和外部阴影变化 | 明确当前输入位置 | 正常 |
|
||||
| 输入用户名 | 键盘输入 | 受控输入更新,支持 `autocomplete=username` | 减少重复输入 | 正常 |
|
||||
| 输入密码 | 键盘输入 | 默认掩码,支持 `autocomplete=current-password` | 保护敏感信息并支持密码管理器 | 正常 |
|
||||
| 显示或隐藏密码 | 点击眼睛图标 | `password/text` 类型切换,可访问名称同步切换 | 帮助用户核对密码 | 正常 |
|
||||
| 空表单提交 | 点击提交或按 Enter | 浏览器原生 required 校验阻止请求并聚焦用户名 | 及早阻止无效操作 | 正常 |
|
||||
| 有效表单提交 | 点击提交或在密码框按 Enter | 清空旧错误;按钮禁用;显示加载文案;发起登录请求 | 提供明确进度并防止重复提交 | 正常 |
|
||||
| 登录失败 | API 返回失败 | 显示本地化错误;按钮恢复可用;错误区域 `role="alert"`/`aria-live` 并获得焦点 | 支持修正后重试 | 正常 |
|
||||
| 登录成功 | API 返回 token 和 handoff code | 使用 `location.replace` 前往目标前端 handoff 页,保留 `next` | 完成认证并返回原任务 | 正常 |
|
||||
| 前往注册 | 点击注册链接 | 前往注册页并保留 `next` | 为无账号用户提供明确替代路径 | 正常 |
|
||||
|
||||
## 6. 响应式测试矩阵
|
||||
|
||||
测试日期:2026-06-04。浏览器:Playwright Chromium。
|
||||
|
||||
| 视口 | 横向越界 | 页面纵向滚动 | 卡片内部滚动 | 卡片完整位于视口 | 结论 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `320×568` | 无 | 无 | 无 | 是 | 通过 |
|
||||
| `375×667` | 无 | 无 | 无 | 是 | 通过 |
|
||||
| `390×844` | 无 | 无 | 无 | 是 | 通过 |
|
||||
| `844×390` 横屏 | 无 | 无 | 无 | 是 | 通过 |
|
||||
| `768×1024` | 无 | 无 | 无 | 是 | 通过 |
|
||||
| `1024×768` | 无 | 无 | 无 | 是 | 通过 |
|
||||
| `1365×900` | 无 | 无 | 无 | 是 | 通过 |
|
||||
| `1920×1080` | 无 | 无 | 无 | 是 | 通过 |
|
||||
|
||||
## 7. 可访问性与触控检查
|
||||
|
||||
### 已通过
|
||||
|
||||
- 页面存在一个清晰的 `h1`。
|
||||
- Logo 有 `alt="Boardware logo"`。
|
||||
- 用户名和密码均存在与 input 关联的 label。
|
||||
- Tab 顺序符合 DOM 和视觉顺序:ZH → EN → 用户名 → 密码 → 显示密码 → 登录 → 注册。
|
||||
- 密码显示按钮具有动态可访问名称。
|
||||
- 用户名和密码支持浏览器自动填充。
|
||||
- 输入框、语言按钮、显示密码按钮、提交按钮和注册链接的实际命中区域均不小于 `44×44px`。
|
||||
- 登录按钮具有本地化可访问名称。
|
||||
- 登录失败错误使用 `role="alert"` 与 `aria-live`,并在失败后聚焦错误区域。
|
||||
|
||||
### 待继续观察
|
||||
|
||||
- 本轮未使用真实屏幕阅读器做端到端朗读,只通过 DOM、焦点和 Playwright 辅助信息验证。
|
||||
- 登录背景图片仍可继续做 WebP/AVIF 与响应式加载优化。
|
||||
|
||||
## 8. 已修复问题与遗留优化
|
||||
|
||||
### 已修复:手机横屏双层纵向滚动
|
||||
|
||||
- 复现:使用 `844×390` 访问登录页。
|
||||
- 用户看到:卡片底部超出首屏;页面可滚动,卡片内部也可滚动。
|
||||
- 影响:用户难以判断应滚动页面还是卡片;单独滚动卡片后仍无法看到注册链接。
|
||||
- 相关实现:
|
||||
- `.auth-page` 在 `<=920px` 时保留顶部和底部 padding。
|
||||
- `.auth-card.login-card` 同时设置基于 `100vh` 的 `max-height` 和 `overflow-y:auto`。
|
||||
- 复测结论:`844×390` 无横向越界、无双层滚动,提交和注册入口均在首屏可达。
|
||||
|
||||
### 已修复:登录按钮缺少可访问名称
|
||||
|
||||
- 复现:使用键盘 Tab 到登录按钮,或检查辅助功能树。
|
||||
- 用户影响:屏幕阅读器只能识别为无名称按钮。
|
||||
- 相关实现:提交按钮默认仅渲染箭头 SVG,SVG 为 `aria-hidden`。
|
||||
- 复测结论:按钮拥有本地化 `aria-label`,加载状态继续表达“登录中”。
|
||||
|
||||
### 已修复:错误反馈缺少可访问播报和焦点恢复
|
||||
|
||||
- 复现:提交错误凭据。
|
||||
- 当前反馈:显示“接口错误 401: 用户名或密码错误”,但无 `role="alert"`、无 `aria-live`,失败后焦点未落在错误或字段上。
|
||||
- 用户影响:技术错误码增加理解成本;屏幕阅读器和键盘用户可能不知道提交已经失败。
|
||||
- 复测结论:
|
||||
- 用户文案不暴露 HTTP 状态与“接口错误”前缀。
|
||||
- 文案说明原因和恢复方式,例如“用户名或密码错误,请检查后重试”。
|
||||
- 错误使用 `role="alert"`/`aria-live`,并聚焦错误摘要。
|
||||
|
||||
### 已修复:关键次级操作点击区域过小
|
||||
|
||||
- 影响范围:语言按钮、显示密码按钮、注册链接。
|
||||
- 复测结论:语言按钮、显示密码按钮、提交和注册链接实际命中区域均达到至少 `44×44px`。
|
||||
|
||||
### 遗留优化:登录背景资源偏大
|
||||
|
||||
- `login-background.png` 约 `1.3MB`,当前未提供 WebP/AVIF 或响应式尺寸。
|
||||
- 用户影响:弱网和移动网络下首屏背景显示较慢。
|
||||
- 建议验收标准:提供 WebP/AVIF,并根据视口加载合理尺寸;保留背景空间避免布局变化。
|
||||
|
||||
## 9. 当前实现的正向 UX
|
||||
|
||||
- 页面只有一个主操作,层级清晰。
|
||||
- 桌面使用背景品牌视觉和右侧卡片,主任务聚焦明确。
|
||||
- 手机输入字号为 `16px`,避免 iOS 自动放大。
|
||||
- 请求中禁用提交按钮,可防止重复登录。
|
||||
- 错误区域预留最小高度,错误出现时不会明显推动后续内容。
|
||||
- `next` 参数在注册跳转和成功 handoff 中均正确保留。
|
||||
- 语言选择刷新后保持。
|
||||
- 所有实测视口均无横向越界。
|
||||
|
||||
## 10. 后续验收清单
|
||||
|
||||
- [x] 修复横屏双层滚动后,重新测试 `844×390` 和更低高度视口。
|
||||
- [x] 为提交按钮添加本地化可访问名称。
|
||||
- [x] 改善登录失败文案、错误播报和焦点恢复。
|
||||
- [x] 扩大语言、显示密码和注册链接的触控区域。
|
||||
- [x] 评估并实现持续可见的字段标签。
|
||||
- [ ] 优化登录背景图片格式和响应式加载。
|
||||
- [ ] 在 Safari、iOS Safari 和 Android Chrome 验证动态地址栏与 `100vh` 行为。
|
||||
- [ ] 使用屏幕阅读器完成一次端到端登录测试。
|
||||
|
||||
## 11. 本轮测试证据
|
||||
|
||||
- 自动化结果:`/tmp/beaver-login-qa-results.json`
|
||||
- 截图目录:`/tmp/beaver-login-qa-shots`
|
||||
- 临时测试脚本:`/tmp/beaver-ui-qa-tests/login-page-qa.spec.js`
|
||||
- 测试命令:
|
||||
|
||||
```bash
|
||||
./node_modules/.bin/playwright test login-page-qa.spec.js \
|
||||
--config=/tmp/beaver-ui-qa-tests/pw.config.js \
|
||||
--workers=1
|
||||
```
|
||||
346
docs/ui-ux/pages/chat-workbench.md
Normal file
346
docs/ui-ux/pages/chat-workbench.md
Normal file
@ -0,0 +1,346 @@
|
||||
# 主应用:对话工作台 UI/UX
|
||||
|
||||
## 1. 页面定义
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 页面名称 | 对话工作台 |
|
||||
| 路由 | `/` |
|
||||
| 页面实现 | `app-instance/frontend/app/(app)/page.tsx` |
|
||||
| 全局外壳 | `components/AppShell.tsx`、`components/Header.tsx`、`components/AuthGuard.tsx`、`components/AppRuntimeBridge.tsx` |
|
||||
| 消息区 | `components/chat-workbench/ChatWorkbench.tsx`、`MessageList.tsx`、`AgentTeamBlock.tsx` |
|
||||
| 进度区 | `components/chat-workbench/CurrentSessionProgressSidebar.tsx` |
|
||||
| 核心任务 | 选择或创建会话,发送文本或附件,与 Assistant 协作并处理任务验收 |
|
||||
| 测试状态 | 已完成修复并复测通过;所有实测视口无页面横向越界、无小点击目标,核心交互可用 |
|
||||
|
||||
本页是主应用默认工作区。它同时承载全局导航、会话管理、消息时间线、Agent 运行过程、任务验收、附件上传、消息输入和当前任务进度。
|
||||
|
||||
## 2. 信息架构与组件层级
|
||||
|
||||
```text
|
||||
AppShell
|
||||
├── Header 固定头部,z-index: 50,高 64px
|
||||
│ ├── Beaver 品牌入口
|
||||
│ ├── 桌面主导航:对话、Task、通知、技能、文件、工具、智能体、Outlook、市场、配置
|
||||
│ ├── 窄屏菜单按钮与移动导航面板
|
||||
│ ├── 连接状态
|
||||
│ ├── LanguageSwitcher
|
||||
│ └── 账号按钮
|
||||
│ └── Account Popover 浮层,含账号信息和退出登录
|
||||
└── main.pt-16
|
||||
└── AuthGuard
|
||||
├── AppRuntimeBridge 会话列表、WebSocket 与运行状态同步
|
||||
└── ChatPage 高度 calc(100vh - 4rem)
|
||||
├── Desktop Session Sidebar md 及以上左侧固定 280px
|
||||
│ ├── 新对话按钮
|
||||
│ └── 最近对话滚动列表
|
||||
│ └── 会话选择按钮 + 44px 归档按钮
|
||||
├── Mobile Session Drawer 手机最近对话抽屉
|
||||
├── Conversation Column 中央弹性宽度
|
||||
│ ├── ChatWorkbench
|
||||
│ │ └── MessageList
|
||||
│ │ ├── 空状态
|
||||
│ │ ├── 用户消息
|
||||
│ │ ├── Assistant Markdown 消息
|
||||
│ │ ├── 消息附件
|
||||
│ │ ├── 任务卡与查看任务链接
|
||||
│ │ ├── 验收操作与内联反馈面板
|
||||
│ │ ├── Agent Team / Subtask 卡片
|
||||
│ │ └── 思考中状态
|
||||
│ └── Composer
|
||||
│ ├── 当前任务或修改任务状态胶囊
|
||||
│ ├── 待上传附件列表
|
||||
│ └── 输入器
|
||||
│ ├── textarea
|
||||
│ ├── 添加附件
|
||||
│ ├── 思考模式
|
||||
│ └── 发送
|
||||
└── Current Session Progress
|
||||
├── >= xl:右侧固定 380px 侧栏
|
||||
└── < xl:右上角浮动按钮 + 右侧抽屉
|
||||
```
|
||||
|
||||
## 3. 布局、位置与层级
|
||||
|
||||
### 全局头部
|
||||
|
||||
- 固定在视口顶部,高度 `64px`,页面主体使用 `pt-16` 避免被遮挡。
|
||||
- 桌面宽屏显示品牌、完整主导航、连接状态、语言和账号。
|
||||
- `2xl` 以下收起完整导航,显示菜单按钮;点击后从左侧滑入独立导航抽屉。
|
||||
- 移动导航抽屉使用完全不透明的应用背景色、右侧阴影和独立滚动;抽屉外显示深色遮罩,底层页面不可交互且不会透过抽屉显示。
|
||||
- “对话、通知、技能”等导航项区域和每个导航项均显式使用不透明应用表面背景,不依赖底层页面或透明父容器。
|
||||
- 抽屉最大宽度为 `320px`,不会在平板或窄屏桌面退化为横向铺满的双列透明菜单。
|
||||
- 层级顺序为页面内容 < 遮罩 < 导航抽屉 < 固定头部;打开时锁定页面滚动。
|
||||
- 账号和语言入口在 `320px` 到宽屏范围内始终可达。
|
||||
|
||||
### 工作台主体
|
||||
|
||||
- 主体为横向 `flex`,高度 `calc(100vh - 4rem)`。
|
||||
- 左侧会话栏在 `md` 及以上显示;手机默认隐藏,使用“最近对话”按钮打开抽屉。
|
||||
- 中央对话列使用 `flex-1 min-w-0`,消息区占剩余高度,输入器固定在对话列底部。
|
||||
- 输入器外层在手机使用较小左右边距,`md` 及以上恢复桌面边距;textarea 高度范围为 `72px` 到 `200px`。
|
||||
- 当存在运行中任务时:
|
||||
- `xl` 及以上显示右侧固定 `380px` 进度栏。
|
||||
- 小于 `xl` 时显示右上角 `44×44px` 浮动按钮,点击后打开右侧抽屉。
|
||||
|
||||
### 消息与运行过程
|
||||
|
||||
- 消息时间线使用独立滚动区域,中央内容最大宽度 `5xl`。
|
||||
- 用户消息右对齐、深色圆角气泡;Assistant 消息左对齐、透明背景。
|
||||
- Assistant 创建任务后显示任务卡;待验收消息下方显示接受、需要修改、放弃。
|
||||
- Agent Team 作为较大的运行过程区块插入消息时间线,内部 Subtask 卡片可横向排列并点击选中。
|
||||
- 当前任务状态胶囊位于输入器上方,点击可前往任务详情。
|
||||
|
||||
### 弹层与覆盖层
|
||||
|
||||
| 层 | 触发入口 | 当前行为 |
|
||||
| --- | --- | --- |
|
||||
| 账号 Popover | 头部账号按钮 | 右对齐浮层;点击按钮打开;`Escape` 关闭;退出登录后跳往认证门户 |
|
||||
| 移动导航抽屉 | `2xl` 以下的汉堡菜单 | 左侧滑入不透明面板;显示背景遮罩;点击遮罩、导航项或按 `Escape` 关闭 |
|
||||
| 进度抽屉 | `< xl` 的右上角进度按钮 | 背景遮罩 + 右侧抽屉;点击遮罩或关闭按钮可关闭 |
|
||||
| 接受反馈面板 | 待验收消息的“接受” | 在消息下方内联展开,不遮挡页面 |
|
||||
| 修改任务状态 | 待验收消息的“需要修改” | 不打开弹窗;聚焦底部输入器并切换提示文案 |
|
||||
| 放弃确认弹窗 | 待验收消息的“放弃” | 先说明后果,确认后才提交 `abandon` |
|
||||
| 归档确认弹窗 | 会话行归档按钮 | 先说明会话将从最近对话移除,确认后归档 |
|
||||
|
||||
## 4. 页面状态
|
||||
|
||||
| 状态 | 当前表现 | UX 目的 | 测试结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| 认证加载 | `AuthGuard` 显示加载中 | 避免未认证内容闪现 | 本轮未单独注入慢认证 |
|
||||
| 空会话 | 中央显示 Beaver 和“发送消息开始对话” | 给出明确起点 | 通过 |
|
||||
| 已加载会话 | 显示消息、任务卡和运行过程 | 恢复历史上下文 | 通过 |
|
||||
| 切换会话 | 更新消息、进度、当前任务和输入草稿 | 快速在多任务间切换 | 通过,逐会话草稿正常保留 |
|
||||
| 思考模式关闭或开启 | 按钮使用 `aria-pressed`,状态写入 localStorage | 让用户控制推理深度并保持偏好 | 通过 |
|
||||
| 输入与换行 | `Enter` 发送,`Shift+Enter` 换行 | 提高聊天输入效率 | 通过 |
|
||||
| 空输入 | 发送按钮禁用 | 防止无效提交 | 通过 |
|
||||
| 发送中或思考中 | 禁用发送,并显示思考中状态 | 防止重复发送并提供过程反馈 | HTTP 模拟流程通过;真实 WebSocket 流未测 |
|
||||
| 附件上传中 | 显示文件名、大小和进度条 | 表达上传进度 | 模拟上传通过 |
|
||||
| 附件就绪 | 显示“就绪”,可移除 | 发送前确认附件 | 通过,但移除按钮可访问性不合格 |
|
||||
| 待验收 | 显示接受、需要修改、放弃 | 让用户决定任务后续 | 三类操作均通过 |
|
||||
| 接受反馈展开 | 显示可选备注、取消和提交 | 支持带上下文验收 | 通过 |
|
||||
| 修改任务 | 输入器切换为修改要求并自动聚焦 | 降低修改路径成本 | 通过 |
|
||||
| 放弃任务 | 点击后打开确认弹窗,确认后提交并显示已放弃 | 防止误终止任务 | 通过 |
|
||||
| 进度侧栏或抽屉 | 显示整体进度、步骤和产物 | 在不离开对话的情况下理解执行状态 | 通过 |
|
||||
| 账号弹层 | 显示账号信息和退出按钮 | 提供会话级账号入口 | 打开、Escape 关闭、退出登录均通过 |
|
||||
|
||||
## 5. 操作与 UX 逻辑
|
||||
|
||||
### 全局头部
|
||||
|
||||
| 操作 | 触发方式 | 状态变化与反馈 | UX 目的 | 当前结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 返回对话页 | 点击 Beaver 或“对话” | 导航到 `/` | 提供稳定主页入口 | 正常 |
|
||||
| 主导航切换 | 桌面点击导航项;窄屏点击菜单后选择导航项 | 前往对应主应用页面,当前项高亮 | 在产品模块间切换 | 正常 |
|
||||
| 切换 ZH/EN | 点击语言按钮 | 页面文案立即变化并持久化 | 支持中英文用户 | 正常 |
|
||||
| 打开账号弹层 | 点击账号按钮 | 显示账号信息与退出登录 | 集中账号操作 | 宽屏和移动端均正常 |
|
||||
| 关闭账号弹层 | 按 `Escape` 或点击外部 | 浮层关闭并返回原页面 | 提供清晰退出路径 | `Escape` 实测正常 |
|
||||
| 退出登录 | 点击退出登录 | 清除 access/refresh token,跳往认证门户 `/login?next=/` | 安全退出并保留返回路径 | 正常 |
|
||||
|
||||
### 会话栏
|
||||
|
||||
| 操作 | 触发方式 | 状态变化与反馈 | UX 目的 | 当前结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 新对话 | 点击顶部主按钮 | 创建新 session、清空消息和输入、显示空状态 | 快速开始新任务 | 正常 |
|
||||
| 选择会话 | 点击会话按钮,或键盘聚焦后 Enter/Space | 切换消息、当前任务、运行进度和草稿 | 支持并行工作 | 正常 |
|
||||
| 保留草稿 | 在不同会话输入后切换 | 每个 session 恢复各自草稿 | 防止上下文切换丢失输入 | 正常 |
|
||||
| 归档会话 | 点击会话行右侧 44px 归档按钮并确认 | 归档后从列表移除;失败时插入 Assistant 错误消息 | 清理历史会话并降低误操作 | 正常 |
|
||||
|
||||
### 消息、任务与 Agent 过程
|
||||
|
||||
| 操作 | 触发方式 | 状态变化与反馈 | UX 目的 | 当前结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 查看任务 | 点击消息任务卡或当前任务胶囊 | 前往 `/tasks/<taskId>` | 深入任务详情 | 路由实测正常 |
|
||||
| 查看消息附件 | 点击图片或文件链接 | 图片新窗口打开;文件下载 | 获取 Assistant 或用户提供的内容 | 本轮未连接真实文件服务 |
|
||||
| 选择 Agent 卡片 | 点击 Subtask 卡片 | 卡片出现选中 ring | 明确当前关注的运行单元 | 正常 |
|
||||
| 接受任务 | 点击“接受” | 展开可选备注面板 | 在提交前允许补充验收上下文 | 正常 |
|
||||
| 取消接受 | 点击反馈面板“取消” | 关闭面板并清空备注 | 避免误提交 | 正常 |
|
||||
| 提交接受 | 输入可选备注后点击“提交” | 提交 `accept`,显示“已接受” | 完成任务验收 | 正常 |
|
||||
| 请求修改 | 点击“需要修改” | 输入器自动聚焦并切换修改提示;发送后提交 `revise` | 让修改请求复用主输入器 | 正常 |
|
||||
| 放弃任务 | 点击“放弃”后确认 | 确认后提交 `abandon`,显示已放弃 | 终止不再需要的任务并降低误操作 | 正常 |
|
||||
|
||||
### 输入器与附件
|
||||
|
||||
| 操作 | 触发方式 | 状态变化与反馈 | UX 目的 | 当前结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 输入消息 | 点击 textarea 并输入 | 自动增长,最大高度 `200px`;写入当前会话草稿 | 支持多行消息并防止输入丢失 | 正常 |
|
||||
| 换行 | `Shift+Enter` | 插入换行,不发送 | 支持结构化输入 | 正常 |
|
||||
| 键盘发送 | `Enter` | 清空输入、显示用户消息、等待回复 | 提高高频对话效率 | 正常 |
|
||||
| 点击发送 | 点击发送按钮 | 与键盘发送相同 | 为鼠标和触控提供明确入口 | 正常 |
|
||||
| 空输入发送 | 空输入时点击或按 Enter | 发送按钮禁用,无请求 | 防止无效提交 | 正常 |
|
||||
| 添加附件 | 点击加号并选择文件 | 上传并显示进度、错误或就绪状态 | 在对话中补充文件上下文 | 模拟上传正常 |
|
||||
| 超过 50MB 文件 | 选择过大文件 | 前端显示“最大 50MB”错误 | 及早阻止无效上传 | 代码路径确认;本轮未真实构造 50MB 文件 |
|
||||
| 移除待发送附件 | 点击附件行右侧 X | 从待发送列表移除 | 发送前修正附件选择 | 正常,按钮有文件名相关可访问名称 |
|
||||
| 切换思考模式 | 点击“思考” | `aria-pressed` 切换并持久化 | 让用户控制回答方式 | 正常 |
|
||||
|
||||
### 进度面板
|
||||
|
||||
| 操作 | 触发方式 | 状态变化与反馈 | UX 目的 | 当前结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 查看桌面进度 | `xl` 及以上自动显示 | 右侧固定展示进度、步骤和产物 | 保持过程透明 | 正常 |
|
||||
| 打开进度抽屉 | `< xl` 点击右上角浮动按钮 | 打开遮罩和右侧抽屉 | 在较窄空间按需查看进度 | 正常 |
|
||||
| 关闭进度抽屉 | 点击 X 或遮罩 | 返回对话页 | 避免抽屉阻塞主任务 | 正常 |
|
||||
| 打开产物链接 | 点击产物行 | 有 URL 时新窗口打开 | 快速访问结果 | 本轮未访问外部 URL |
|
||||
|
||||
## 6. 响应式测试矩阵
|
||||
|
||||
测试日期:2026-06-04。浏览器:Playwright Chromium。页面使用模拟 API 数据,所有布局渲染、点击、键盘操作和截图均在真实浏览器中执行。
|
||||
|
||||
| 视口 | 页面横向越界 | textarea 实测宽度 | 会话栏 | 进度展示 | 结论 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `320×568` | 无 | `262px` | 移动抽屉 | 抽屉可打开 | 通过 |
|
||||
| `375×667` | 无 | `317px` | 移动抽屉 | 抽屉可打开 | 通过 |
|
||||
| `390×844` | 无 | `332px` | 移动抽屉 | 抽屉可打开 | 通过 |
|
||||
| `844×390` 横屏 | 无 | `466px` | 固定 `280px` | 抽屉可打开 | 通过 |
|
||||
| `768×1024` | 无 | `390px` | 固定 `280px` | 抽屉可打开 | 通过 |
|
||||
| `1024×768` | 无 | `646px` | 固定 `280px` | 抽屉可打开 | 通过 |
|
||||
| `1365×900` | 无 | `607px` | 固定 `280px` | 固定右栏 `380px` | 通过 |
|
||||
| `1920×1080` | 无 | `990px` | 固定 `280px` | 固定右栏 `380px` | 通过 |
|
||||
|
||||
### 关键量化证据
|
||||
|
||||
- `320px`、`375px`、`390px` 手机竖屏均无页面横向越界。
|
||||
- 手机竖屏 textarea 实测宽度分别为 `262px`、`317px`、`332px`。
|
||||
- `390px` 手机宽度下,账号入口 `44×44px` 且完整位于视口内。
|
||||
- `1365px` 宽度下,账号入口完整位于视口内,完整导航收起为菜单入口。
|
||||
- 最终复测中抽样视口可见小目标数为 `0`。
|
||||
|
||||
## 7. 可访问性与触控检查
|
||||
|
||||
### 已通过
|
||||
|
||||
- 发送按钮具有本地化 `aria-label`。
|
||||
- 思考模式使用 `aria-pressed` 表达开关状态。
|
||||
- 归档按钮、进度按钮和进度关闭按钮具有可访问名称。
|
||||
- 账号 Popover 可使用 `Escape` 关闭。
|
||||
- 修改任务后焦点自动移动到主输入器。
|
||||
- 空输入时发送按钮使用原生 `disabled`。
|
||||
- 长连续文本在桌面消息气泡中正常换行,未引发页面横向越界。
|
||||
- 会话选择使用语义按钮,并通过 `aria-current` 表达当前会话。
|
||||
- 附件移除按钮具有包含文件名的可访问名称。
|
||||
- 主输入器和反馈输入器均有 label 关联。
|
||||
- 所有最终抽样视口中,可见可交互控件命中区域均不小于 `44×44px`。
|
||||
|
||||
### 已修复
|
||||
|
||||
| 等级 | 问题 | 证据与影响 | 建议验收标准 |
|
||||
| --- | --- | --- | --- |
|
||||
| P1 | 会话行不可键盘操作 | 已改为语义按钮 | 支持 Enter/Space,当前会话使用 `aria-current` |
|
||||
| P2 | 归档入口依赖悬停且目标过小 | 已改为 44px 归档按钮,并支持键盘焦点显示 | 触控环境可操作,目标至少 `44×44px` |
|
||||
| P2 | 待发送附件移除按钮无名称且过小 | 已添加“移除附件 <文件名>”名称并扩大到 44px | 屏幕阅读器和触控均可用 |
|
||||
| P2 | 多个关键操作小于 `44px` | 已扩大导航、语言、账号、任务链接、反馈、附件、思考模式等控件 | 最终抽样视口小目标数为 `0` |
|
||||
| P2 | 主输入器和反馈输入器仅使用 placeholder | 已补充 label | 输入后仍有稳定字段语义 |
|
||||
| P2 | 归档与放弃缺少确认或撤销 | 已补充站内确认弹窗 | 操作需确认后执行 |
|
||||
|
||||
## 8. 已修复问题与仍需观察项
|
||||
|
||||
### 已修复:手机竖屏下对话核心流程不可用
|
||||
|
||||
- 复现:使用 `320×568`、`375×667` 或 `390×844` 访问 `/`。
|
||||
- 用户看到:左侧 `280px` 会话栏占据绝大部分视口,中央消息与输入器被压缩到右侧细条;需要横向滚动。
|
||||
- 实测证据:文档宽度为 `549px`;textarea 可见宽度仅 `16px`。
|
||||
- 影响:用户无法正常阅读消息、输入内容或使用发送与反馈操作。
|
||||
- 相关实现:
|
||||
- `app/(app)/page.tsx` 的会话栏始终为 `w-[280px] shrink-0`。
|
||||
- 输入器始终保留 `px-8`。
|
||||
- 复测结论:
|
||||
- 手机竖屏无页面横向滚动。
|
||||
- 会话列表改为抽屉,默认优先显示对话内容。
|
||||
- 输入器在 `320px` 宽度仍可完整输入和发送。
|
||||
|
||||
### 已修复:全局头部缺少响应式策略
|
||||
|
||||
- 复现:在 `1365px` 及以下查看头部。
|
||||
- 用户看到:
|
||||
- `1365px` 时连接状态、语言和账号区域相互挤压,账号入口右侧被裁切。
|
||||
- `1024px`、`768px`、`390px` 时大量导航与账号入口位于视口外。
|
||||
- 实测证据:
|
||||
- `1365px` 时账号入口右边界 `1392px`。
|
||||
- `390px` 时账号入口起点 `x=1302px`。
|
||||
- 相关实现:`components/Header.tsx` 始终渲染 10 个完整文字导航项,并使用固定三列网格。
|
||||
- 复测结论:在 `320px` 到 `1365px` 提供可操作的移动或紧凑导航,账号、语言和当前模块入口始终可达。
|
||||
|
||||
### 已修复:会话选择不支持键盘
|
||||
|
||||
- 复现:使用 Tab 键尝试选择最近对话。
|
||||
- 当前结果:会话行是带 `onClick` 的 `div`,无 `role`、无 `tabIndex`,不会进入键盘焦点顺序。
|
||||
- 用户影响:键盘和部分辅助技术用户无法切换会话。
|
||||
- 相关实现:`app/(app)/page.tsx` 会话列表行。
|
||||
- 复测结论:会话项使用语义按钮,支持键盘操作,当前项具有 `aria-current`。
|
||||
|
||||
### 已修复:放弃任务立即执行,无确认或撤销
|
||||
|
||||
- 复现:点击待验收消息的“放弃”。
|
||||
- 当前结果:点击后先打开确认弹窗,确认后才提交 `abandon` 请求。
|
||||
- 用户影响:误触会直接终止任务,恢复路径不明确。
|
||||
- 相关实现:`MessageList.tsx` 的放弃按钮直接调用 `onFeedback(..., 'abandon')`。
|
||||
- 复测结论:放弃前明确说明后果并要求确认。
|
||||
|
||||
### 已修复:会话归档入口可发现性和误操作保护不足
|
||||
|
||||
- 归档按钮默认 `opacity-0`,只有悬停会话行才显示。
|
||||
- 按钮实测约 `18×18px`,触控设备不易发现和点击。
|
||||
- 点击后先打开确认弹窗,确认后归档。
|
||||
- 复测结论:触控环境可操作;命中区域至少 `44×44px`;归档前要求确认。
|
||||
|
||||
### 已修复:关键触控目标普遍偏小
|
||||
|
||||
- 影响范围:头部导航、语言切换、归档、查看任务、接受/修改/放弃、反馈取消/提交、附件、思考模式。
|
||||
- 用户影响:手机、平板和运动能力受限用户更容易误触或漏触。
|
||||
- 复测结论:最终抽样视口可见小目标数为 `0`。
|
||||
|
||||
### 已修复:待发送附件移除按钮缺少可访问名称
|
||||
|
||||
- 复现:添加附件并等待“就绪”。
|
||||
- 当前结果:右侧 X 为无文本、无 `aria-label`、无 `title` 的按钮,尺寸约 `14×14px`。
|
||||
- 复测结论:名称包含操作和文件名,命中区域至少 `44×44px`。
|
||||
|
||||
### 已修复:移动视口高度使用 `100vh`
|
||||
|
||||
- 工作台使用 `h-[calc(100vh-4rem)]`,没有使用动态视口单位。
|
||||
- 用户影响:iOS Safari 等动态地址栏环境中,底部输入器可能被浏览器 UI 遮挡或发生高度跳变。
|
||||
- 复测结论:工作台主体改用 `100dvh`。仍建议在移动真机验证输入法与地址栏变化。
|
||||
|
||||
## 9. 当前实现的正向 UX
|
||||
|
||||
- 新对话、会话切换、发送、修改、验收和进度查看形成完整工作流。
|
||||
- 每个会话独立保留输入草稿,切换后可恢复。
|
||||
- `Enter` 发送和 `Shift+Enter` 换行符合聊天产品惯例。
|
||||
- 空输入时禁用发送;异步发送时避免重复提交。
|
||||
- 修改任务会自动聚焦输入器并切换提示文案,路径清晰。
|
||||
- 接受任务先展开可选备注,允许取消后再提交。
|
||||
- 任务卡和当前任务胶囊均可直接进入任务详情。
|
||||
- 进度面板同时表达总体进度、步骤和产物;小于 `xl` 时抽屉打开与关闭正常。
|
||||
- 账号 Popover 支持 `Escape`,退出登录正确清除 token 并返回认证门户。
|
||||
- 本轮所有实测交互均未产生控制台错误或框架错误覆盖层。
|
||||
|
||||
## 10. 后续验收清单
|
||||
|
||||
- [x] 为手机和窄屏设计会话抽屉、紧凑头部和主对话优先布局。
|
||||
- [x] 修复 `320px`、`375px`、`390px` 横向越界并确保输入器完整可用。
|
||||
- [x] 修复 `1365px` 及以下头部挤压和账号入口越界。
|
||||
- [x] 将会话行改为完整的键盘与辅助技术可操作元素。
|
||||
- [x] 为放弃任务和归档会话增加确认。
|
||||
- [x] 扩大所有关键触控目标到至少 `44×44px`。
|
||||
- [x] 为附件移除按钮添加本地化可访问名称。
|
||||
- [x] 为主输入器和反馈输入器补充明确 label。
|
||||
- [ ] 使用真实 WebSocket、真实上传、网络失败和超时状态复测。
|
||||
- [ ] 测试 50MB 边界、上传失败、发送失败和反馈失败恢复路径。
|
||||
- [ ] 在 iOS Safari、Android Chrome 和桌面 Safari 复测动态视口与输入法。
|
||||
- [ ] 使用屏幕阅读器完成发送、切换会话和任务验收流程。
|
||||
|
||||
## 11. 本轮测试证据
|
||||
|
||||
- 自动化结果:`/tmp/beaver-chat-qa-results.json`
|
||||
- 截图目录:`/tmp/beaver-chat-qa-shots`
|
||||
- 临时测试脚本:`/tmp/beaver-ui-qa-tests/chat-page-qa.spec.js`
|
||||
- API 环境:使用确定性模拟数据;真实浏览器负责页面渲染、点击、键盘、路由、响应式测量与截图。
|
||||
- 自动化结果:`4 passed`。
|
||||
- 测试命令:
|
||||
|
||||
```bash
|
||||
/tmp/beaver-ui-qa-tests/node_modules/.bin/playwright test \
|
||||
/tmp/beaver-ui-qa-tests/chat-page-qa.spec.js \
|
||||
--config=/tmp/beaver-ui-qa-tests/pw.config.js \
|
||||
--workers=1
|
||||
```
|
||||
120
docs/ui-ux/pages/files.md
Normal file
120
docs/ui-ux/pages/files.md
Normal file
@ -0,0 +1,120 @@
|
||||
# 主应用:文件页 UI/UX
|
||||
|
||||
## 1. 页面定义
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 页面名称 | 文件管理 |
|
||||
| 路由 | `/files` |
|
||||
| 页面实现 | `app-instance/frontend/app/(app)/files/page.tsx` |
|
||||
| 关键组件 | `FilesPage`、`FilePreviewPanel`、`FileIcon`、`ScrollArea` |
|
||||
| 核心任务 | 浏览目录、创建文件夹、上传文件、预览文本/Markdown/图片/二进制文件、下载文件、删除文件或目录 |
|
||||
| 测试状态 | 已修复并复测通过;本地与 `terminaltest` 生产实例均通过 |
|
||||
|
||||
## 2. 信息架构与组件层级
|
||||
|
||||
```text
|
||||
AppShell
|
||||
└── main.pt-16
|
||||
└── /files
|
||||
├── Page Header
|
||||
│ ├── h1 Files
|
||||
│ ├── New folder
|
||||
│ ├── Upload
|
||||
│ ├── hidden file input
|
||||
│ └── Refresh
|
||||
├── Breadcrumbs
|
||||
│ ├── Files root
|
||||
│ └── 当前路径 segments
|
||||
├── New directory form
|
||||
│ ├── Folder name input
|
||||
│ ├── Create
|
||||
│ └── Cancel
|
||||
└── Content grid
|
||||
├── File list panel
|
||||
│ ├── Loading state
|
||||
│ ├── Error state + Retry
|
||||
│ ├── Empty state
|
||||
│ └── File / Directory rows
|
||||
│ ├── Open / Preview primary button
|
||||
│ ├── Download
|
||||
│ └── Delete
|
||||
└── FilePreviewPanel
|
||||
├── Empty preview
|
||||
├── Loading preview
|
||||
├── Error preview
|
||||
├── File metadata
|
||||
├── Download
|
||||
├── Image preview
|
||||
├── Markdown preview
|
||||
├── Text preview
|
||||
└── Binary fallback
|
||||
```
|
||||
|
||||
## 3. 布局与响应式规则
|
||||
|
||||
- 页面外层使用 `max-w-7xl`,移动端内边距为 `16px`,桌面端为 `24px`。
|
||||
- 内容区默认单列,`lg` 以上改为左侧文件列表、右侧预览双栏。
|
||||
- 左侧文件列表和右侧预览都使用 `min-w-0`,避免长文件名、Markdown、代码块撑破页面。
|
||||
- 文件行移动端为上下结构:上方文件信息,下方下载/删除操作;`sm` 以上恢复横向布局。
|
||||
- 下载/删除在移动端始终可见,桌面端保留 hover 显示。
|
||||
- 根目录也允许创建文件夹和上传文件;空态文案与按钮行为一致。
|
||||
- 面包屑、文件行主按钮、下载、删除、创建、取消、刷新等主要可点击目标均为 `44px` 以上。
|
||||
|
||||
## 4. 操作与 UX 逻辑
|
||||
|
||||
| 操作 | 触发方式 | 状态变化与反馈 | UX 目的 | 当前结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 刷新文件列表 | 点击 Refresh | 调用 browse 接口重新加载当前路径 | 获取最新目录状态 | 正常 |
|
||||
| 根目录新建文件夹 | 点击 New folder,输入名称,点击 Create | 调用 mkdir,成功后刷新列表 | 空 workspace 也能开始组织文件 | 正常 |
|
||||
| 根目录上传文件 | 点击 Upload,选择文件 | 调用 upload,显示进度并刷新列表 | 空 workspace 也能添加文件 | 正常 |
|
||||
| 进入目录 | 点击目录主区域 | 调用 browse,更新 breadcrumbs 和列表 | 浏览层级文件 | 正常 |
|
||||
| 返回上级或根目录 | 点击 breadcrumbs | 调用 browse 对应路径 | 快速导航 | 正常 |
|
||||
| 预览文件 | 点击文件主区域 | 调用 preview,右侧显示内容 | 快速查看文件 | 正常 |
|
||||
| 下载文件 | 点击文件行或预览区 Download | 调用 download,浏览器下载 blob | 获取原始文件 | 正常 |
|
||||
| 删除文件/目录 | 点击 Delete 并确认 | 调用 delete,成功后从列表移除 | 管理文件空间,防误删 | 正常 |
|
||||
| 加载失败 | browse 接口失败 | 显示错误、详情和 Retry | 明确异常恢复路径 | 正常 |
|
||||
| 二进制文件预览 | 文件不可预览 | 显示不可直接预览提示 | 避免乱码 | 正常 |
|
||||
|
||||
## 5. 响应式测试矩阵
|
||||
|
||||
测试日期:2026-06-04。浏览器:Playwright Chromium。环境:本地 dev server `http://127.0.0.1:3080` 与生产实例 `terminaltest`;API 使用模拟数据,真实浏览器执行点击、上传入口、目录切换、预览、下载、删除确认和截图。
|
||||
|
||||
| 页面/状态 | 视口 | 横向越界 | 小触控目标 | 关键结论 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 根目录空态 | `390×844` | 无 | 0 | New folder 和 Upload 在根目录可用 |
|
||||
| 文件预览 | `390×844` | 无 | 0 | 可进入目录、预览 Markdown、下载、删除 |
|
||||
| 加载错误 | `390×844` | 无 | 0 | 错误和 Retry 可见 |
|
||||
| 文件预览 | `320×568` | 无 | 0 | 长文件名、操作按钮、Markdown 预览均未撑破页面 |
|
||||
| 文件预览 | `390×844` | 无 | 0 | 手机竖屏布局清晰 |
|
||||
| 文件预览 | `844×390` 横屏 | 无 | 0 | 横屏无页面级横向滚动 |
|
||||
| 文件预览 | `768×1024` | 无 | 0 | 平板布局稳定 |
|
||||
| 文件预览 | `1365×900` | 无 | 0 | 桌面双栏布局正常 |
|
||||
|
||||
### 关键量化证据
|
||||
|
||||
- 本地文件页 QA 自动化用例 `4 passed`。
|
||||
- 部署到 `terminaltest` 后,同一套文件页 QA 自动化用例 `4 passed`。
|
||||
- 根目录空态中 `New folder` 和 `Upload` 均为 enabled。
|
||||
- 创建文件夹调用 `/api/user-files/mkdir?path=docs`。
|
||||
- 下载调用 `/api/user-files/download`。
|
||||
- 删除调用 `/api/user-files/delete`,并经过浏览器确认。
|
||||
- 实测 `320×568`、`390×844`、`844×390`、`768×1024`、`1365×900` 均无页面级横向越界。
|
||||
- 所有实测视口可见小触控目标数为 `0`。
|
||||
|
||||
## 6. 已修复问题
|
||||
|
||||
| 等级 | 问题 | 修复 |
|
||||
| --- | --- | --- |
|
||||
| P1 | 根目录空态提示可上传/新建,但按钮被禁用 | 根目录允许 New folder 和 Upload |
|
||||
| P1 | `320px` 下文件列表被 `minmax(360px,440px)` 和长文件名撑破页面 | 默认单列改为 `minmax(0,1fr)`,列表和预览加 `min-w-0` |
|
||||
| P1 | 文件行里嵌套下载/删除 role button,移动端不可发现且触控小 | 改为主打开按钮 + 独立下载/删除按钮,移动端始终可见 |
|
||||
| P2 | 文件行长文件名与操作按钮互相挤压 | 移动端文件行改为信息在上、操作在下 |
|
||||
| P2 | Refresh 是 icon-only 且缺少可访问名称 | 补充 `aria-label/title`,命中区为 `44px` |
|
||||
| P2 | 面包屑按钮和文件主按钮触控高度不足 | 固定为 `44px` 以上 |
|
||||
| P2 | Markdown/text 预览长内容可能撑破页面 | 预览区增加容器内换行和 preserved long text 规则 |
|
||||
|
||||
## 7. 剩余观察项
|
||||
|
||||
- 本轮自动化使用模拟 API 数据覆盖常见文件类型;真实大文件、超大图片、深层目录和上传失败恢复仍需持续观察。
|
||||
- 浏览器原生下载行为只验证 download API 被调用,未校验操作系统保存结果。
|
||||
106
docs/ui-ux/pages/marketplace.md
Normal file
106
docs/ui-ux/pages/marketplace.md
Normal file
@ -0,0 +1,106 @@
|
||||
# 主应用:市场页 UI/UX
|
||||
|
||||
## 1. 页面定义
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 页面名称 | 技能市场 |
|
||||
| 路由 | `/marketplace` |
|
||||
| 页面实现 | `app-instance/frontend/app/(app)/marketplace/page.tsx` |
|
||||
| 关键组件 | `MarketplacePage`、`SkillDetailView`、`Tabs`、`Card`、`Button`、`Input` |
|
||||
| 核心任务 | 搜索 SkillHub 技能、排序筛选、打开详情、查看说明/文件/版本、安装或更新技能 |
|
||||
| 测试状态 | 已修复并复测通过;本地与 `terminaltest` 生产实例均通过 |
|
||||
|
||||
## 2. 信息架构与组件层级
|
||||
|
||||
```text
|
||||
AppShell
|
||||
└── main.pt-16
|
||||
└── /marketplace
|
||||
├── Page Header
|
||||
│ ├── h1 Marketplace
|
||||
│ └── 页面说明
|
||||
├── Search Form
|
||||
│ ├── 搜索输入框
|
||||
│ └── Search
|
||||
├── Error Card
|
||||
├── Search Results
|
||||
│ ├── Sort Buttons
|
||||
│ ├── Starred only Filter
|
||||
│ ├── Skill Button Cards
|
||||
│ └── Pagination
|
||||
└── Skill Detail
|
||||
├── Back to search
|
||||
├── Detail Header
|
||||
│ ├── namespace/download/star/version badges
|
||||
│ ├── title/summary
|
||||
│ └── Install/Reinstall
|
||||
├── Overview Tab
|
||||
├── Files Tab
|
||||
│ ├── file list
|
||||
│ └── file preview
|
||||
└── Versions Tab
|
||||
└── version rows
|
||||
```
|
||||
|
||||
## 3. 布局与响应式规则
|
||||
|
||||
- 页面外层使用 `max-w-7xl`,移动端内边距为 `16px`,桌面端为 `24px`。
|
||||
- 搜索区在移动端垂直排列,桌面端输入框和 Search 按钮横向排列。
|
||||
- 结果卡片使用真正的 `button` 语义,而不是仅给 Card 绑定 click,键盘和读屏器都能识别为可操作项。
|
||||
- 技能名、namespace、版本号、文件路径、README markdown 和代码预览都允许在容器内断行。
|
||||
- 详情页文件列表和文件预览在桌面端左右分栏,移动端垂直排列。
|
||||
- Tabs、按钮、输入框和分页操作均满足 `44px` 触控目标。
|
||||
- Markdown 中的长代码、表格和链接不会撑出页面;表格在内容区内横向滚动。
|
||||
|
||||
## 4. 操作与 UX 逻辑
|
||||
|
||||
| 操作 | 触发方式 | 状态变化与反馈 | UX 目的 | 当前结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 搜索技能 | 输入关键词后点击 Search 或提交表单 | 调用 SkillHub search,列表刷新 | 快速定位技能 | 正常 |
|
||||
| 切换排序 | 点击 Relevance/Downloads/Newest | 当前排序按钮进入选中态并重新加载 | 支持按不同意图浏览 | 正常 |
|
||||
| 只看收藏 | 点击 Starred only | 本地筛选 starCount 大于 0 的结果 | 聚焦已收藏技能 | 正常 |
|
||||
| 打开技能详情 | 点击技能卡片按钮 | 加载 detail、version list、SKILL.md | 进入安装前审阅 | 正常 |
|
||||
| 返回搜索 | 点击 Back to search | 清空详情状态并回到结果列表 | 提供明确退出路径 | 正常 |
|
||||
| 切换说明/文件/版本 | 点击 tab | 同页切换内容区 | 让审阅信息分层 | 正常 |
|
||||
| 打开文件 | Files tab 点击文件路径 | 加载并预览文件内容 | 审核技能代码或文档 | 正常 |
|
||||
| 切换版本 | Versions tab 点击版本行 | 加载指定版本详情和 README | 安装前比较版本 | 正常 |
|
||||
| 安装技能 | 点击 Install/Reinstall | 调用 install API,按钮 loading,成功后显示 installed | 完成技能接入 | 正常 |
|
||||
| 加载/安装失败 | API 返回错误 | 显示错误卡片,文本断行 | 明确失败原因并保留当前上下文 | 正常 |
|
||||
|
||||
## 5. 响应式测试矩阵
|
||||
|
||||
测试日期:2026-06-04。浏览器:Playwright Chromium。环境:本地 dev server `http://127.0.0.1:3080` 与生产实例 `terminaltest`;API 使用模拟数据,真实浏览器执行点击、输入、详情打开、文件预览、安装和截图。
|
||||
|
||||
| 页面/状态 | 视口 | 横向越界 | 小触控目标 | 关键结论 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 搜索结果 + 详情 | `320×568` | 无 | 0 | 长 skill 名、namespace 和 summary 不撑破页面 |
|
||||
| 搜索结果 + 详情 | `390×844` | 无 | 0 | 可搜索、打开详情、切换文件并安装 |
|
||||
| 搜索结果 + 详情 | `844×390` 横屏 | 无 | 0 | 横屏内容可滚动,不产生页面级横向滚动 |
|
||||
| 搜索结果 + 详情 | `768×1024` | 无 | 0 | 平板详情布局稳定 |
|
||||
| 搜索结果 + 详情 | `1365×900` | 无 | 0 | 桌面分栏和卡片布局正常 |
|
||||
|
||||
### 关键量化证据
|
||||
|
||||
- 本地与 `terminaltest` 市场/配置组合 QA 自动化用例均 `4 passed`,其中覆盖市场流。
|
||||
- 覆盖搜索、详情、文件 tab、长路径文件预览、安装请求和响应式。
|
||||
- 实测 `320×568`、`390×844`、`844×390`、`768×1024`、`1365×900` 均无页面级横向越界。
|
||||
- 所有实测视口可见小触控目标数为 `0`。
|
||||
- 本地截图保存在 `/tmp/beaver-market-settings-qa-local-shots`,生产截图保存在 `/tmp/beaver-market-settings-qa-prod-shots`。
|
||||
|
||||
## 6. 已修复问题
|
||||
|
||||
| 等级 | 问题 | 修复 |
|
||||
| --- | --- | --- |
|
||||
| P1 | 结果卡片只有 Card click,没有按钮语义,键盘和辅助技术不可稳定操作 | 改为真实 `button` 卡片 |
|
||||
| P1 | 技能详情里的文件路径、版本号、markdown 代码块可能撑破移动端 | 增加 `min-w-0`、`break-all`、`break-words` 和 markdown 预览约束 |
|
||||
| P2 | 页面缺少清晰 `h1`,市场页身份不明确 | 增加语义化标题和说明 |
|
||||
| P2 | 搜索表单在窄屏可能挤压 | 移动端改为纵向布局 |
|
||||
| P2 | Tabs 触控高度不足 44px | 基础 `TabsTrigger` 调整为 `44px` |
|
||||
| P2 | 排序、筛选、返回、安装、分页等操作触控尺寸不统一 | 基础按钮和页面操作统一提升到 `44px` |
|
||||
| P3 | 长 namespace 和版本 badge 只截断或可能撑宽 | 改为容器内断行 |
|
||||
|
||||
## 7. 剩余观察项
|
||||
|
||||
- 本轮使用模拟 SkillHub 数据;真实市场里若存在复杂 HTML/Markdown 表格或超大文件,仍需持续抽样检查。
|
||||
- 当前安装成功后停留在详情页并更新 installed 状态,后续可考虑增加轻量 toast,增强成功反馈。
|
||||
114
docs/ui-ux/pages/mcp-tools.md
Normal file
114
docs/ui-ux/pages/mcp-tools.md
Normal file
@ -0,0 +1,114 @@
|
||||
# 主应用:工具页 UI/UX
|
||||
|
||||
## 1. 页面定义
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 页面名称 | 工具 / MCP 工具管理 |
|
||||
| 路由 | `/mcp` |
|
||||
| 页面实现 | `app-instance/frontend/app/(app)/mcp/page.tsx` |
|
||||
| 关键组件 | `MCPPage`、`Dialog`、`Tabs`、MCP 服务卡片、工具详情面板 |
|
||||
| 核心任务 | 查看本地/在线 MCP 服务、新增服务、编辑服务、测试连接、删除服务、查看已发现工具 |
|
||||
| 测试状态 | 已修复并复测通过;本地与 `terminaltest` 生产实例均通过 |
|
||||
|
||||
## 2. 信息架构与组件层级
|
||||
|
||||
```text
|
||||
AppShell
|
||||
└── main.pt-16
|
||||
└── /mcp
|
||||
├── Page Header
|
||||
│ ├── h1 Tools
|
||||
│ ├── Refresh
|
||||
│ └── Add tool server
|
||||
├── Add / Edit MCP Dialog
|
||||
│ ├── ID
|
||||
│ ├── Tool timeout
|
||||
│ ├── Connection mode tabs
|
||||
│ │ ├── Remote MCP server
|
||||
│ │ │ ├── URL
|
||||
│ │ │ ├── Auth mode
|
||||
│ │ │ ├── AuthZ permissions preview
|
||||
│ │ │ └── Headers JSON
|
||||
│ │ └── Install and launch
|
||||
│ │ ├── Command
|
||||
│ │ └── Arguments
|
||||
│ └── Cancel / Save
|
||||
├── Error Card
|
||||
├── Tool kind tabs
|
||||
│ ├── Local tools
|
||||
│ └── Online tools
|
||||
└── Content grid
|
||||
├── MCP service list
|
||||
│ ├── Selectable service content
|
||||
│ └── Edit / Test / Delete
|
||||
└── Tool details panel
|
||||
├── Empty selection state
|
||||
├── Empty tools state
|
||||
└── Tool cards
|
||||
```
|
||||
|
||||
## 3. 布局与响应式规则
|
||||
|
||||
- 页面外层使用 `max-w-6xl`,移动端内边距为 `16px`,桌面端为 `24px`。
|
||||
- 移动端头部按钮自动换行,`Refresh` 和 `Add tool server` 均为 `44px` 高度。
|
||||
- 内容区默认单列;`xl` 以上变为左侧 MCP 服务列表、右侧工具详情双栏。
|
||||
- MCP 服务卡片的可选中区域和编辑/测试/删除按钮分离,避免卡片 role button 内嵌按钮。
|
||||
- 移动端服务卡片头部改为纵向布局,徽章不会挤出右侧边界。
|
||||
- 长 ID、URL、command、Audience、Scopes、last error 和工具描述都在容器内换行。
|
||||
- 新增/编辑弹窗限制在当前视口内,内容可滚动,底部 Cancel/Save 保持可达。
|
||||
- 页面主要按钮、Tab 和服务选择区域均满足 `44px` 触控目标。
|
||||
|
||||
## 4. 操作与 UX 逻辑
|
||||
|
||||
| 操作 | 触发方式 | 状态变化与反馈 | UX 目的 | 当前结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 刷新工具服务 | 点击 Refresh | 重新拉取 MCP servers、tools、AuthZ 状态 | 获取最新连接和工具发现状态 | 正常 |
|
||||
| 切换本地/在线工具 | 点击 Local tools / Online tools | 切换服务列表,并清空右侧选中态 | 避免跨分类误看详情 | 正常 |
|
||||
| 选择服务 | 点击服务卡片内容区域或键盘 Enter/Space | 卡片高亮,右侧显示对应工具 | 查看该 MCP 暴露的工具 | 正常 |
|
||||
| 测试服务 | 点击 Test | 按钮显示 loading,成功后刷新状态 | 验证 MCP 当前可连接性 | 正常 |
|
||||
| 新增远程服务 | 点击 Add tool server,填写 URL/Auth/Header,点击 Save | 校验 ID、URL、timeout、Headers JSON,成功后关闭弹窗并刷新 | 接入已部署 MCP 服务 | 正常 |
|
||||
| 新增本地服务 | 弹窗中切换 Install and launch,填写 command/args,点击 Save | 校验 ID、command、timeout,成功后关闭弹窗并刷新 | 接入本地 stdio MCP 进程 | 正常 |
|
||||
| 编辑服务 | 点击 Edit | 打开同一弹窗并带入原配置,保存后刷新 | 修改用户自定义 MCP 配置 | 正常 |
|
||||
| 删除服务 | 点击 Delete,确认浏览器弹窗 | 确认后调用 delete,成功后取消选中并刷新 | 防止误删 MCP 配置 | 正常 |
|
||||
| JSON 输入错误 | Headers JSON 非对象或语法错误 | 页面错误卡显示解析错误,弹窗保持打开 | 保留用户输入并指出修复方向 | 正常 |
|
||||
| 加载失败 | servers 接口失败 | 显示错误卡,不阻断页面头部操作 | 明确异常原因并允许重新刷新 | 正常 |
|
||||
|
||||
## 5. 响应式测试矩阵
|
||||
|
||||
测试日期:2026-06-04。浏览器:Playwright Chromium。环境:本地 dev server `http://127.0.0.1:3080` 与生产实例 `terminaltest`;API 使用模拟数据,真实浏览器执行点击、输入、确认弹窗和截图。
|
||||
|
||||
| 页面/状态 | 视口 | 横向越界 | 小触控目标 | 关键结论 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 本地服务详情 | `390×844` | 无 | 0 | 可选择服务、查看工具详情、测试连接 |
|
||||
| 在线服务详情 | `390×844` | 无 | 0 | 超长 ID、URL、Audience、错误信息未撑破页面 |
|
||||
| 加载错误 | `390×844` | 无 | 0 | 错误卡可读,头部操作仍可见 |
|
||||
| 在线服务详情 | `320×568` | 无 | 0 | 窄屏卡片徽章和长字符串均正常换行 |
|
||||
| 在线服务详情 | `390×844` | 无 | 0 | 手机竖屏可完成查看与操作 |
|
||||
| 在线服务详情 | `844×390` 横屏 | 无 | 0 | 横屏无页面级横向滚动 |
|
||||
| 在线服务详情 | `768×1024` | 无 | 0 | 平板单列布局稳定 |
|
||||
| 在线服务详情 | `1365×900` | 无 | 0 | 桌面双栏布局正常 |
|
||||
|
||||
### 关键量化证据
|
||||
|
||||
- 本地工具页 QA 自动化用例 `4 passed`。
|
||||
- 部署到 `terminaltest` 后,同一套工具页 QA 自动化用例 `4 passed`。
|
||||
- 覆盖服务选择、工具详情、Tab 切换、Test、Add、Edit、Delete confirm、Refresh、JSON 错误和加载错误。
|
||||
- 实测 `320×568`、`390×844`、`844×390`、`768×1024`、`1365×900` 均无页面级横向越界。
|
||||
- 所有实测视口可见小触控目标数为 `0`。
|
||||
|
||||
## 6. 已修复问题
|
||||
|
||||
| 等级 | 问题 | 修复 |
|
||||
| --- | --- | --- |
|
||||
| P1 | `320px` 下在线服务卡片徽章、Audience 长字符串和错误信息横向越界 | 卡片头部移动端纵向布局,所有长字段加 `break-all/min-w-0` |
|
||||
| P1 | 新增/编辑弹窗内容过高时 Save 按钮在移动端被挤出视口 | Dialog 限制 `max-height`、启用内部滚动,底部操作区 sticky |
|
||||
| P1 | 删除 MCP 服务没有确认,误删风险高 | 删除前增加浏览器确认弹窗 |
|
||||
| P2 | 服务卡片整体 `role=button` 内部嵌套 Edit/Test/Delete 按钮 | 改为内容区域可选中,操作按钮独立 |
|
||||
| P2 | Refresh、Add、Local/Online tabs 和卡片操作按钮触控高度不足 | 统一提升到 `44px` |
|
||||
| P2 | Headers JSON 错误只显示页面级错误,弹窗可能遮挡操作 | 错误卡可读,弹窗保持打开且操作按钮可达 |
|
||||
|
||||
## 7. 剩余观察项
|
||||
|
||||
- 本轮自动化使用模拟 MCP 与 AuthZ 数据;真实 MCP 连接超时、鉴权 403 和工具发现异常仍需在后续联测中观察。
|
||||
- 当前删除使用浏览器原生确认弹窗,后续可统一为应用内确认 Dialog,以保持视觉一致性。
|
||||
135
docs/ui-ux/pages/notifications.md
Normal file
135
docs/ui-ux/pages/notifications.md
Normal file
@ -0,0 +1,135 @@
|
||||
# 主应用:通知页 UI/UX
|
||||
|
||||
## 1. 页面定义
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 页面名称 | 通知中心 |
|
||||
| 通知列表路由 | `/notifications` |
|
||||
| 通知详情路由 | `/notifications/[scheduledRunId]` |
|
||||
| 页面实现 | `app-instance/frontend/app/(app)/notifications/page.tsx`、`app-instance/frontend/app/(app)/notifications/[scheduledRunId]/page.tsx` |
|
||||
| 关键组件 | `NotificationsPage`、`NotificationDetailPage`、`ChatWorkbench`、`MessageList`、`Button`、`Badge` |
|
||||
| 核心任务 | 查看定时任务生成的通知、进入通知详情、刷新通知、对通知结果提出本次修改或未来规则调整、打开已接入 Task |
|
||||
| 测试状态 | 已修复并复测通过;本地与 `terminaltest` 生产实例均通过 |
|
||||
|
||||
## 2. 信息架构与组件层级
|
||||
|
||||
```text
|
||||
AppShell
|
||||
└── main.pt-16
|
||||
├── /notifications
|
||||
│ ├── Page Header
|
||||
│ │ ├── h1 Notifications
|
||||
│ │ ├── 页面说明
|
||||
│ │ └── 刷新按钮
|
||||
│ ├── Error Card
|
||||
│ └── Notification List Panel
|
||||
│ ├── Loading State
|
||||
│ ├── Empty State
|
||||
│ └── Notification Link Row/Card
|
||||
│ ├── 标题
|
||||
│ ├── 状态 Badge
|
||||
│ ├── 摘要
|
||||
│ ├── 生成时间
|
||||
│ ├── job 名称
|
||||
│ └── 桌面端箭头
|
||||
└── /notifications/[scheduledRunId]
|
||||
├── Detail Header
|
||||
│ ├── 返回通知列表
|
||||
│ ├── 标题
|
||||
│ ├── 状态 / 已接入 Task Badge
|
||||
│ ├── 生成时间
|
||||
│ ├── 刷新按钮
|
||||
│ └── 查看任务按钮
|
||||
├── Error Banner
|
||||
├── ChatWorkbench
|
||||
│ └── MessageList
|
||||
│ ├── 用户原始请求
|
||||
│ └── 通知生成结果
|
||||
└── Reply Panel
|
||||
├── 修改这次
|
||||
├── 以后按这样
|
||||
├── 已接入提示
|
||||
└── 回复输入框 + 发送
|
||||
```
|
||||
|
||||
## 3. 布局与响应式规则
|
||||
|
||||
### 通知列表
|
||||
|
||||
- 外层使用 `max-w-6xl`,移动端内边距为 `16px`,桌面端为 `24px`。
|
||||
- 列表区域是独立白色面板,内部垂直滚动,不产生页面级横向滚动。
|
||||
- 移动端列表项按单列卡片阅读:标题、摘要、时间和 job 名称上下排列。
|
||||
- `md` 及以上使用网格列展示摘要、时间、job 名称和箭头,方便桌面端扫描。
|
||||
- 长标题、长摘要和长 job 名称都使用容器内断行,避免 ID 或连续字符串撑破卡片。
|
||||
- 刷新按钮高度为 `44px`,满足移动端触控目标要求。
|
||||
|
||||
### 通知详情
|
||||
|
||||
- 页面整体高度为 `calc(100vh - 4rem)`,位于全局 header 下方。
|
||||
- 顶部详情 header 使用浅色背景和底部分隔线,承载返回、标题、状态、生成时间和操作按钮。
|
||||
- 移动端标题允许换行,不再只截断;长 scheduled run id 或长标题不会撑破页面。
|
||||
- 中间 `ChatWorkbench` 使用剩余高度展示消息流。
|
||||
- 底部回复区是独立面板,包含两个意图按钮和按需出现的输入框。
|
||||
- 回复输入框有可见 label,不依赖 placeholder 作为唯一说明。
|
||||
- 返回、刷新、查看任务、意图选择、发送等关键操作均为 `44px` 高度。
|
||||
|
||||
## 4. 操作与 UX 逻辑
|
||||
|
||||
| 操作 | 触发方式 | 状态变化与反馈 | UX 目的 | 当前结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 刷新通知列表 | 点击列表页 Refresh | 调用 `GET /api/notifications`,列表重新渲染 | 获取最新定时通知 | 正常 |
|
||||
| 打开通知详情 | 点击通知列表项 | 跳转 `/notifications/[scheduledRunId]` | 查看通知完整上下文和结果 | 正常 |
|
||||
| 空通知状态 | 列表接口返回空数组 | 显示“暂无通知” | 明确没有可处理内容 | 正常 |
|
||||
| 列表加载失败 | 列表接口失败 | 显示错误卡片 | 让用户知道是接口异常而不是无数据 | 正常 |
|
||||
| 返回通知列表 | 详情页点击返回 | 跳转 `/notifications` | 提供明确退出路径 | 正常 |
|
||||
| 刷新通知详情 | 详情页点击 Refresh | 调用 `GET /api/notifications/[id]` | 重新获取当前通知状态 | 正常 |
|
||||
| 修改这次 | 点击 `Revise this` | 按钮进入选中态,显示“本次通知的修改说明”输入框 | 只影响本次通知结果 | 正常 |
|
||||
| 以后按这样 | 点击 `Apply going forward` | 按钮进入选中态,显示“以后这类通知的调整说明”输入框 | 将用户偏好表达为未来规则 | 正常 |
|
||||
| 发送修改说明 | 输入内容后点击 Send | 调用 `POST /api/chat`,携带 `reply_to_scheduled_run_id` 和 `scheduled_reply_intent` | 把通知反馈接回对话/任务处理流程 | 正常 |
|
||||
| 打开已接入 Task | 已存在 `task_id` 时点击 Open task | 跳转 `/tasks/[taskId]` | 从通知继续跟进任务 | 正常 |
|
||||
| 通知不存在 | 详情接口失败或无数据 | 显示错误信息和返回通知入口 | 提供异常恢复路径 | 正常 |
|
||||
|
||||
## 5. 响应式测试矩阵
|
||||
|
||||
测试日期:2026-06-04。浏览器:Playwright Chromium。环境:本地 dev server `http://127.0.0.1:3080` 与生产实例 `terminaltest`;API 使用模拟数据,真实浏览器执行点击、输入、跳转和截图。
|
||||
|
||||
| 页面 | 视口 | 横向越界 | 小触控目标 | 关键结论 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 通知列表 | `320×568` | 无 | 0 | 长标题和长 job 名在卡片内换行 |
|
||||
| 通知列表 | `390×844` | 无 | 0 | 手机竖屏可刷新、进入详情 |
|
||||
| 通知列表 | `844×390` 横屏 | 无 | 0 | 横屏无页面级横向滚动 |
|
||||
| 通知列表 | `768×1024` | 无 | 0 | 平板列表布局稳定 |
|
||||
| 通知列表 | `1365×900` | 无 | 0 | 桌面网格列可扫描 |
|
||||
| 通知空态 | `390×844` | 无 | 0 | 空态居中显示 |
|
||||
| 通知错误态 | `390×844` | 无 | 0 | 错误信息在卡片内可读 |
|
||||
| 通知详情 | `320×568` | 无 | 0 | 顶部标题换行,回复区不遮挡 |
|
||||
| 通知详情 | `390×844` | 无 | 0 | 消息流和底部回复区分层清楚 |
|
||||
| 通知详情 | `768×1024` | 无 | 0 | 平板详情布局正常 |
|
||||
| 通知详情 | `1365×900` | 无 | 0 | 桌面详情布局正常 |
|
||||
| 通知详情错误态 | `390×844` | 无 | 0 | 返回通知入口可见 |
|
||||
|
||||
### 关键量化证据
|
||||
|
||||
- 本地通知页 QA 自动化用例 `4 passed`。
|
||||
- 部署到 `terminaltest` 后,同一套通知页 QA 自动化用例 `4 passed`。
|
||||
- 列表实测视口 `320×568`、`390×844`、`844×390`、`768×1024`、`1365×900` 均无页面级横向越界。
|
||||
- 详情实测视口 `320×568`、`390×844`、`768×1024`、`1365×900` 均无页面级横向越界。
|
||||
- 所有实测视口可见小触控目标数为 `0`。
|
||||
- 回复提交实测请求包含 `reply_to_scheduled_run_id` 和 `scheduled_reply_intent=revise_once`。
|
||||
|
||||
## 6. 已修复问题
|
||||
|
||||
| 等级 | 问题 | 修复 |
|
||||
| --- | --- | --- |
|
||||
| P1 | 通知列表长标题、长 job 名在移动端被右侧裁切或存在撑破风险 | 标题、摘要和 job 名增加容器内断行;列表项保留 `min-w-0` |
|
||||
| P2 | 列表 Refresh 按钮高度只有 36px,低于移动端触控标准 | 调整为 `44px` |
|
||||
| P2 | 详情页返回、Refresh、Revise、Apply going forward 等操作高度不足 44px | 统一调整为 `44px` |
|
||||
| P2 | 详情页标题只截断,移动端无法判断完整通知主题 | 改为可换行并控制在容器内 |
|
||||
| P2 | 详情回复输入框依赖 placeholder 表达含义 | 增加可见 label,并根据意图区分本次修改/未来调整 |
|
||||
| P3 | 意图按钮缺少明确 pressed 状态语义 | 增加 `aria-pressed` |
|
||||
|
||||
## 7. 剩余观察项
|
||||
|
||||
- 本地 mocked API 场景会出现 WebSocket 握手失败日志,因为测试 token 不对应真实后端 WS;该日志不影响通知页 HTTP 交互和布局结论。
|
||||
- 后续接入真实用户历史通知时,仍需持续观察第三方系统生成的异常长 Markdown、附件和错误文本。
|
||||
121
docs/ui-ux/pages/outlook.md
Normal file
121
docs/ui-ux/pages/outlook.md
Normal file
@ -0,0 +1,121 @@
|
||||
# 主应用:Outlook 页 UI/UX
|
||||
|
||||
## 1. 页面定义
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 页面名称 | Outlook |
|
||||
| 路由 | `/outlook` |
|
||||
| 页面实现 | `app-instance/frontend/app/(app)/outlook/page.tsx` |
|
||||
| 关键组件 | `OutlookPage`、`MessageCard`、`EventCard`、`Field`、邮件详情 Dialog、日程详情 Dialog |
|
||||
| 核心任务 | 配置 Outlook/EWS 连接、测试连接、保存启用、断开连接、查看收件箱/发件箱、分页、查看邮件详情、查看未来 7 天日程 |
|
||||
| 测试状态 | 已修复并复测通过;本地与 `terminaltest` 生产实例均通过 |
|
||||
|
||||
## 2. 信息架构与组件层级
|
||||
|
||||
```text
|
||||
AppShell
|
||||
└── main.pt-16
|
||||
└── /outlook
|
||||
├── Status Header
|
||||
│ ├── h1 Outlook
|
||||
│ ├── connection / MCP / provider badges
|
||||
│ ├── mailbox / timezone / last refresh
|
||||
│ ├── Inbox / Sent / Calendar stats
|
||||
│ └── Refresh
|
||||
├── Error Card
|
||||
├── Warning Card
|
||||
├── View Tabs
|
||||
│ ├── Inbox
|
||||
│ ├── Sent
|
||||
│ ├── Calendar
|
||||
│ └── Settings
|
||||
├── Inbox / Sent
|
||||
│ ├── Mailbox header
|
||||
│ ├── Refresh / Previous / Next
|
||||
│ └── Message rows
|
||||
├── Calendar
|
||||
│ ├── Week header
|
||||
│ ├── Previous week / This week / Next week / Refresh
|
||||
│ └── Day cards
|
||||
├── Settings
|
||||
│ ├── Connection settings form
|
||||
│ ├── Test connection / Save and enable / Disconnect
|
||||
│ ├── Test result panel
|
||||
│ └── Connection status panel
|
||||
├── Message detail Dialog
|
||||
│ ├── metadata panel
|
||||
│ └── body preview
|
||||
└── Event detail Dialog
|
||||
├── organizer / location / attendees
|
||||
└── notes
|
||||
```
|
||||
|
||||
## 3. 布局与响应式规则
|
||||
|
||||
- 页面外层使用 `max-w-7xl`,移动端内边距为 `16px`,桌面端为 `24px`。
|
||||
- 顶部状态区有语义化 `h1`,长邮箱、时区和刷新时间允许容器内断行。
|
||||
- 已配置时显示 Inbox、Sent、Calendar、Settings;未配置时只显示 Settings。
|
||||
- 邮件列表和日程列表在移动端头部为上下布局,分页和刷新按钮可换行。
|
||||
- 邮件详情和日程详情 Dialog 在移动端使用 `left/right/top/bottom: 16px` 的独立面板,内容可滚动,不再被默认居中动画撑出视口。
|
||||
- 设置表单字段均有 `label htmlFor` 与 input `id` 绑定,支持可访问名称和自动化定位。
|
||||
- 输入框、主要按钮、分页按钮、刷新 icon、Dialog 关闭按钮均满足 `44px` 触控目标。
|
||||
- 邮件正文里的明文链接使用 `min-height: 44px` 的可点击区域,移动端更容易点击。
|
||||
|
||||
## 4. 操作与 UX 逻辑
|
||||
|
||||
| 操作 | 触发方式 | 状态变化与反馈 | UX 目的 | 当前结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 初次加载已配置状态 | 打开 `/outlook` | 加载 status 后自动加载 overview | 顶部统计和 warning 不显示过期空值 | 正常 |
|
||||
| 刷新 Outlook | 点击 Refresh | 刷新 status/overview,并刷新当前 tab 数据 | 获取最新连接、邮件或日程状态 | 正常 |
|
||||
| 切换 Inbox/Sent/Calendar/Settings | 点击 tab | 切换视图,按需加载邮箱或日程数据 | 保持页面内工作流清晰 | 正常 |
|
||||
| 查看邮件详情 | 点击邮件行 | 打开邮件详情 Dialog,加载 message-detail | 阅读正文和收发件人信息 | 正常 |
|
||||
| 查看日程详情 | 点击日程卡片 | 打开日程详情 Dialog | 查看地点、参会人和说明 | 正常 |
|
||||
| 邮件分页 | 点击 Previous / Next | 使用当前 skip 加载上一页/下一页 | 浏览更多邮件 | 正常 |
|
||||
| 日程切周 | 点击 Previous week / This week / Next week | 更新 7 天范围并重新加载 events | 快速查看周视图 | 正常 |
|
||||
| 测试连接 | 填写必要字段后点击 Test connection | 显示 testing,成功后显示 sample 与 warning | 保存前验证配置可用 | 正常 |
|
||||
| 保存并启用 | 点击 Save and enable | 成功后清空密码、刷新状态并进入 Inbox | 完成 Outlook 接入 | 正常 |
|
||||
| 断开连接 | 点击 Disconnect 并确认 | 清空 overview、选中详情和分页缓存,回到 Settings | 防止误断开并恢复到配置状态 | 正常 |
|
||||
| 状态加载失败 | status 接口失败 | 显示错误卡 | 明确异常原因 | 正常 |
|
||||
|
||||
## 5. 响应式测试矩阵
|
||||
|
||||
测试日期:2026-06-04。浏览器:Playwright Chromium。环境:本地 dev server `http://127.0.0.1:3080` 与生产实例 `terminaltest`;API 使用模拟数据,真实浏览器执行点击、输入、确认弹窗、分页、详情弹窗和截图。
|
||||
|
||||
| 页面/状态 | 视口 | 横向越界 | 小触控目标 | 关键结论 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 邮件详情 | `390×844` | 无 | 0 | 可打开邮件详情,长主题、收件人、正文和链接均可读 |
|
||||
| 日程详情 | `390×844` | 无 | 0 | 可打开日程详情,长地点和参会人可断行 |
|
||||
| 设置表单 | `390×844` | 无 | 0 | 字段可通过 label 操作,测试/保存/断开流程正常 |
|
||||
| 状态错误 | `390×844` | 无 | 0 | 错误卡可读 |
|
||||
| 邮件详情 + 设置 | `320×568` | 无 | 0 | 窄屏无页面级横向滚动,Dialog 可滚动 |
|
||||
| 邮件详情 + 设置 | `390×844` | 无 | 0 | 手机竖屏布局稳定 |
|
||||
| 邮件详情 + 设置 | `844×390` 横屏 | 无 | 0 | 横屏不出现页面级横向滚动 |
|
||||
| 邮件详情 + 设置 | `768×1024` | 无 | 0 | 平板布局稳定 |
|
||||
| 邮件详情 + 设置 | `1365×900` | 无 | 0 | 桌面布局正常 |
|
||||
|
||||
### 关键量化证据
|
||||
|
||||
- 本地 Outlook 页 QA 自动化用例 `4 passed`。
|
||||
- 部署到 `terminaltest` 后,同一套 Outlook 页 QA 自动化用例 `4 passed`。
|
||||
- 覆盖已配置状态、overview 加载、Inbox、邮件详情、Calendar、日程详情、Settings、Test、Save、Disconnect confirm、状态错误和响应式。
|
||||
- 实测 `320×568`、`390×844`、`844×390`、`768×1024`、`1365×900` 均无页面级横向越界。
|
||||
- 所有实测视口可见小触控目标数为 `0`。
|
||||
|
||||
## 6. 已修复问题
|
||||
|
||||
| 等级 | 问题 | 修复 |
|
||||
| --- | --- | --- |
|
||||
| P1 | 已配置 Outlook 初次进入页面不会主动加载 overview,顶部统计可能一直为 0 | `loadStatus` 在 configured 状态下自动加载 overview |
|
||||
| P1 | 设置表单 label 未绑定 input,辅助技术和自动化无法按字段名定位 | `Field` 增加 `htmlFor`,所有输入框增加稳定 `id` |
|
||||
| P1 | 邮件详情和日程详情 Dialog 在移动端会被默认居中/slide 动画撑出视口 | 移动端改为四边 inset 面板并覆盖 slide 偏移 |
|
||||
| P1 | 长邮箱、EWS URL、邮件主题、地点、参会人、warning 可能撑破布局 | 关键文本增加 `min-w-0`、`break-all` 或 `break-words` |
|
||||
| P2 | Refresh、分页、表单操作、Dialog 关闭等触控目标不足 | 统一提升到 `44px` |
|
||||
| P2 | 断开 Outlook 连接没有确认,误操作风险高 | Disconnect 前增加浏览器确认弹窗 |
|
||||
| P2 | 邮件正文中的明文链接触控高度过小 | 链接改为可换行的 `44px` 点击区域 |
|
||||
| P2 | 邮件/日程列表头部在移动端按钮挤压 | 头部改为移动端纵向布局,操作按钮可换行 |
|
||||
|
||||
## 7. 剩余观察项
|
||||
|
||||
- 本轮自动化使用模拟 Outlook API 数据;真实 EWS 超时、鉴权失败、HTML 邮件复杂表格和超大正文仍需持续观察。
|
||||
- 当前 Disconnect 使用浏览器原生确认弹窗,后续可统一为应用内确认 Dialog,以保持视觉一致性。
|
||||
120
docs/ui-ux/pages/settings.md
Normal file
120
docs/ui-ux/pages/settings.md
Normal file
@ -0,0 +1,120 @@
|
||||
# 主应用:配置页 UI/UX
|
||||
|
||||
## 1. 页面定义
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 页面名称 | 配置 |
|
||||
| 主路由 | `/settings` |
|
||||
| 同实现路由 | `/status` |
|
||||
| 页面实现 | `app-instance/frontend/app/(app)/settings/page.tsx` re-export `app-instance/frontend/app/(app)/status/page.tsx` |
|
||||
| 关键组件 | `StatusPage`、Provider Dialog、Channel Detail Dialog、Connector Dialog、Restart Dialog、`InfoRow`、`Field` |
|
||||
| 核心任务 | 查看实例运行信息、调整智能体参数、配置模型提供商、配置/连接通道、查看通道事件、重启实例 |
|
||||
| 测试状态 | 已修复并复测通过;本地与 `terminaltest` 生产实例均通过 |
|
||||
|
||||
## 2. 信息架构与组件层级
|
||||
|
||||
```text
|
||||
AppShell
|
||||
└── main.pt-16
|
||||
└── /settings
|
||||
├── Page Header
|
||||
│ ├── h1 Settings
|
||||
│ ├── 页面说明
|
||||
│ └── Refresh
|
||||
├── Instance runtime Card
|
||||
│ ├── Runtime Logs
|
||||
│ ├── Restart instance
|
||||
│ └── config/workspace InfoRow
|
||||
├── Agent configuration Card
|
||||
│ ├── model InfoRow
|
||||
│ ├── max tokens / temperature / max tool iterations
|
||||
│ └── Save agent config
|
||||
├── Providers Card
|
||||
│ └── provider button cards
|
||||
├── Provider Dialog
|
||||
│ ├── enable switch
|
||||
│ ├── model / API key / API base / timeout
|
||||
│ └── Cancel / Save
|
||||
├── Channels Card
|
||||
│ └── connector/channel button cards
|
||||
├── Channel Detail Dialog
|
||||
│ ├── channel title and id
|
||||
│ ├── connection settings
|
||||
│ ├── credential fields
|
||||
│ ├── policy fields
|
||||
│ ├── channel state InfoRows
|
||||
│ └── recent events
|
||||
├── Connector Dialog
|
||||
│ ├── display name / domain / mode
|
||||
│ ├── QR or plugin session
|
||||
│ └── Close / Refresh / Start connection
|
||||
└── Restart Dialog
|
||||
├── warning text
|
||||
└── Cancel / Restart
|
||||
```
|
||||
|
||||
## 3. 布局与响应式规则
|
||||
|
||||
- `/settings` 和 `/status` 使用同一实现;当前文档按配置页维护。
|
||||
- 页面外层使用 `max-w-6xl`,移动端内边距为 `16px`,桌面端为 `24px`。
|
||||
- `InfoRow` 使用响应式网格,长配置路径、workspace、model、channel id 和 URL 都在容器内断行。
|
||||
- Provider 与 Channel 卡片是按钮语义,移动端单列,平板/桌面多列。
|
||||
- 所有 Dialog 移动端使用 `width: calc(100vw - 2rem)`、顶部 16px、最大高度 `calc(100vh - 2rem)`、内部滚动;桌面端保持居中面板。
|
||||
- Dialog 直接子元素统一 `min-w-0`,避免长标题或表单网格把弹窗撑出视口。
|
||||
- 输入框、按钮、下拉、tab、switch 等可见操作均满足 `44px` 触控目标。
|
||||
- 通道表单的 `Display name`、`Account ID` 等关键字段具备 label/input 绑定,可按标签点击和自动化定位。
|
||||
|
||||
## 4. 操作与 UX 逻辑
|
||||
|
||||
| 操作 | 触发方式 | 状态变化与反馈 | UX 目的 | 当前结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 刷新配置 | 点击 Refresh | 重新调用 `/api/status` | 获取最新实例状态 | 正常 |
|
||||
| 打开运行日志 | 点击 Runtime Logs | 跳转 `/logs` | 进入运行排障入口 | 正常 |
|
||||
| 打开重启确认 | 点击 Restart instance | 打开确认 Dialog | 防止误重启 | 正常 |
|
||||
| 确认重启 | Dialog 点击 Restart | 调用 `/api/runtime/restart`,成功后关闭并延迟刷新 | 应用配置变更或恢复运行态 | 正常 |
|
||||
| 保存智能体配置 | 修改参数后点击 Save agent config | 校验数值并调用 `/api/agent-config` | 调整模型上下文和推理参数 | 正常 |
|
||||
| 打开 Provider | 点击 OpenAI/DeepSeek 等卡片 | 打开 Provider Dialog 并填入当前值 | 配置模型接入 | 正常 |
|
||||
| 保存 Provider | Dialog 点击 Save | 调用 `/api/providers/{id}/config`,成功后刷新 status | 启用或更新默认提供商 | 正常 |
|
||||
| 打开通道详情 | 点击已存在通道卡片 | 加载 `/api/channels/{id}/config` 和 events | 查看并编辑通道连接 | 正常 |
|
||||
| 保存通道配置 | Dialog 点击 Save channel config | 调用 `/api/channels/{id}/config`,必要时提示重启 | 更新 IM/终端通道配置 | 正常 |
|
||||
| 打开连接器 | 点击可启动的 Weixin/Feishu 卡片 | 打开连接器 Dialog | 启动扫码或插件接入流程 | 正常 |
|
||||
| 状态加载失败 | `/api/status` 失败 | 显示错误卡片和 Retry | 明确异常并提供恢复入口 | 正常 |
|
||||
|
||||
## 5. 响应式测试矩阵
|
||||
|
||||
测试日期:2026-06-04。浏览器:Playwright Chromium。环境:本地 dev server `http://127.0.0.1:3080` 与生产实例 `terminaltest`;API 使用模拟数据,真实浏览器执行点击、输入、弹窗保存、重启确认和截图。
|
||||
|
||||
| 页面/状态 | 视口 | 横向越界 | 小触控目标 | 关键结论 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 配置页 + 飞书通道弹窗 | `320×568` | 无 | 0 | 长 config path、workspace、provider 名和 channel id 均不越界 |
|
||||
| 配置页 + 飞书通道弹窗 | `390×844` | 无 | 0 | 可保存智能体、Provider、通道配置并打开重启确认 |
|
||||
| 配置页 + 飞书通道弹窗 | `844×390` 横屏 | 无 | 0 | 横屏 Dialog 可滚动,不撑出页面 |
|
||||
| 配置页 + 飞书通道弹窗 | `768×1024` | 无 | 0 | 平板布局稳定 |
|
||||
| 配置页 + 飞书通道弹窗 | `1365×900` | 无 | 0 | 桌面卡片和 Dialog 布局正常 |
|
||||
| 状态错误 | `390×844` | 无 | 0 | 错误文案和 Retry 可读可点 |
|
||||
|
||||
### 关键量化证据
|
||||
|
||||
- 本地与 `terminaltest` 市场/配置组合 QA 自动化用例均 `4 passed`,其中覆盖配置流。
|
||||
- 覆盖智能体配置保存、OpenAI Provider 保存、Feishu 通道保存、重启确认、状态错误和响应式。
|
||||
- 实测 `320×568`、`390×844`、`844×390`、`768×1024`、`1365×900` 均无页面级横向越界。
|
||||
- 所有实测视口可见小触控目标数为 `0`。
|
||||
- 本地截图保存在 `/tmp/beaver-market-settings-qa-local-shots`,生产截图保存在 `/tmp/beaver-market-settings-qa-prod-shots`。
|
||||
|
||||
## 6. 已修复问题
|
||||
|
||||
| 等级 | 问题 | 修复 |
|
||||
| --- | --- | --- |
|
||||
| P1 | 配置页 Dialog 在移动端按桌面宽度居中,长内容会撑出或被裁切 | 基础 `DialogContent` 增加移动端 `calc(100vw - 2rem)` 宽度、最大高度、滚动和 `min-w-0` |
|
||||
| P1 | `InfoRow` 的长路径固定 `max-w-[400px] truncate`,在窄屏撑破页面 | 改为响应式网格和 `break-all` |
|
||||
| P1 | 通道表单关键 label 未绑定输入框,真实点击标签和自动化定位不稳定 | `Field` 支持 `htmlFor`,关键字段增加稳定 `id` |
|
||||
| P2 | Provider、Channel 卡片长名称和 channel id 可能撑宽 | 卡片内部增加 `min-w-0`、`break-all` 和可换行文本 |
|
||||
| P2 | Button/Input/Select/Tabs 触控高度不足或不统一 | 基础组件统一提升到 44px 级别 |
|
||||
| P2 | 通道最近事件时间和状态长文本可能挤压 | 改为移动端纵向布局并断行 |
|
||||
| P3 | 错误文案和保存提示可能被长文本撑开 | 增加 `break-words` |
|
||||
|
||||
## 7. 剩余观察项
|
||||
|
||||
- 本轮使用模拟 provider/channel 数据;真实第三方连接器返回超长错误、二维码图片或异常 instructions 时仍需抽样验证。
|
||||
- `/settings` 与 `/status` 共用同一实现,后续如果产品语义拆分,需要分别维护两份页面文档。
|
||||
121
docs/ui-ux/pages/skills.md
Normal file
121
docs/ui-ux/pages/skills.md
Normal file
@ -0,0 +1,121 @@
|
||||
# 主应用:技能页 UI/UX
|
||||
|
||||
## 1. 页面定义
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 页面名称 | 技能管理 |
|
||||
| 主路由 | `/skills` |
|
||||
| 候选子页 | `/skills?tab=candidates` |
|
||||
| 草稿评审子页 | `/skills?tab=drafts` |
|
||||
| 页面实现 | `app-instance/frontend/app/(app)/skills/page.tsx` |
|
||||
| 关键组件 | `PublishedSkillsTable`、`CandidateCard`、`DraftCard`、`SafetyReportPanel`、`EvalReportPanel`、`RawDetails`、`UploadSkillForm` |
|
||||
| 核心任务 | 查看已发布技能、打开技能详情、上传技能、处理学习候选、生成草稿、提交/批准/拒绝/复检/发布草稿 |
|
||||
| 测试状态 | 已修复并复测通过;本地与 `terminaltest` 生产实例均通过 |
|
||||
|
||||
## 2. 信息架构与组件层级
|
||||
|
||||
```text
|
||||
AppShell
|
||||
└── main.pt-16
|
||||
└── /skills
|
||||
├── Page Header
|
||||
│ ├── h1 Skills
|
||||
│ ├── Refresh
|
||||
│ └── Upload skill
|
||||
├── Error Card
|
||||
├── UploadSkillForm
|
||||
├── SkillDetailView
|
||||
│ ├── Overview
|
||||
│ ├── Files
|
||||
│ ├── Versions
|
||||
│ └── Download / Rollback / Disable / Delete
|
||||
└── Tabs
|
||||
├── Published
|
||||
│ ├── < md:技能卡片列表
|
||||
│ └── >= md:技能表格
|
||||
├── Candidates
|
||||
│ └── CandidateCard
|
||||
│ ├── 状态、风险、置信度
|
||||
│ ├── 候选理由和影响范围
|
||||
│ ├── 原始候选数据
|
||||
│ └── Ignore / Synthesize draft / Regenerate
|
||||
└── Draft review
|
||||
└── DraftCard
|
||||
├── 状态、safety、eval badge
|
||||
├── 草稿说明和元数据
|
||||
├── Submit / Approve / Reject / Recheck / Publish
|
||||
├── Proposed skill body
|
||||
├── Publish gates
|
||||
├── Raw draft payload
|
||||
├── Safety report
|
||||
└── Eval report
|
||||
```
|
||||
|
||||
## 3. 布局与响应式规则
|
||||
|
||||
- 页面外层使用 `max-w-6xl` 和响应式内边距:移动端 `16px`,桌面端 `24px`。
|
||||
- tabs 是受控状态,并同步到 URL:候选为 `?tab=candidates`,草稿评审为 `?tab=drafts`。
|
||||
- `runAction -> load()` 后不会再把候选或草稿评审重置到 Published。
|
||||
- 移动端 Published 不显示宽表格,改为技能卡片;桌面端保留表格以便扫描。
|
||||
- Candidates 和 Draft review 的卡片、Markdown、JSON、长 id 都使用容器内断行和 `min-w-0/max-w-full`。
|
||||
- 草稿评审的 eval replay cases 在移动端显示卡片,桌面端显示表格。
|
||||
- 原始数据 `details/summary` 的点击高度为 `44px`。
|
||||
- 主要操作按钮均达到 `44px` 触控目标。
|
||||
|
||||
## 4. 操作与 UX 逻辑
|
||||
|
||||
| 操作 | 触发方式 | 状态变化与反馈 | UX 目的 | 当前结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 切换 Published | 点击 Published tab | URL 移除 `tab` 参数 | 回到已发布技能列表 | 正常 |
|
||||
| 切换 Candidates | 点击 Candidates tab | URL 变为 `/skills?tab=candidates` | 处理学习候选 | 正常 |
|
||||
| 切换 Draft review | 点击 Draft review tab | URL 变为 `/skills?tab=drafts` | 审核和发布草稿 | 正常 |
|
||||
| 刷新技能页 | 点击 Refresh | 重新拉取已发布、候选、草稿数据 | 获取最新技能状态 | 正常 |
|
||||
| 上传技能 | 点击 Upload skill | 展开上传表单,上传成功后刷新 | 将本地技能包进入草稿评审 | 正常 |
|
||||
| 打开技能详情 | 点击 Published 技能 | 显示 `SkillDetailView` | 查看版本、文件和内容 | 正常 |
|
||||
| 生成草稿 | Candidates 点击 Synthesize draft | 调用候选 draft 接口,刷新数据后仍停留 Candidates | 不打断候选处理上下文 | 正常 |
|
||||
| 忽略候选 | Candidates 点击 Ignore | 本地隐藏该候选 | 快速清理不处理候选 | 正常 |
|
||||
| 送审草稿 | Draft review 点击 Submit | 调用 submit 接口,刷新后仍停留 Draft review | 进入人工评审流程 | 正常 |
|
||||
| 批准草稿 | Draft review 点击 Approve | 调用 approve 接口,刷新后仍停留 Draft review | 满足发布门禁前置 | 正常 |
|
||||
| 拒绝草稿 | Draft review 点击 Reject | 调用 reject 接口,刷新后仍停留 Draft review | 阻止不合格草稿继续发布 | 正常 |
|
||||
| 复检安全 | Draft review 点击 Recheck | 调用 safety 接口,刷新后仍停留 Draft review | 获取最新安全结论 | 正常 |
|
||||
| 发布草稿 | Draft review 点击 Publish | 满足门禁后调用 publish;高风险需要确认 | 将草稿转为已发布技能 | 正常 |
|
||||
| 展开原始数据 | 点击 Raw details summary | 展开/收起 JSON | 为审核提供可追溯原始数据 | 正常 |
|
||||
|
||||
## 5. 响应式测试矩阵
|
||||
|
||||
测试日期:2026-06-04。浏览器:Playwright Chromium。环境:本地 dev server `http://127.0.0.1:3080` 与生产实例 `terminaltest`;API 使用模拟数据,真实浏览器执行点击、输入、跳转和截图。
|
||||
|
||||
| 页面 | 视口 | 横向越界 | 小触控目标 | 关键结论 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Candidates | `390×844` | 无 | 0 | 生成草稿后仍停留候选页 |
|
||||
| Draft review | `390×844` | 无 | 0 | 送审和批准后仍停留草稿评审页 |
|
||||
| Draft review | `320×568` | 无 | 0 | 长 draft id、Markdown、eval cases 均未撑破页面 |
|
||||
| Draft review | `390×844` | 无 | 0 | 手机竖屏可完成审核操作 |
|
||||
| Draft review | `844×390` 横屏 | 无 | 0 | 横屏不出现页面级横向滚动 |
|
||||
| Draft review | `768×1024` | 无 | 0 | 平板布局稳定 |
|
||||
| Draft review | `1365×900` | 无 | 0 | 桌面布局正常 |
|
||||
|
||||
### 关键量化证据
|
||||
|
||||
- 本地技能页 QA 自动化用例 `3 passed`。
|
||||
- 部署到 `terminaltest` 后,同一套技能页 QA 自动化用例 `3 passed`。
|
||||
- 候选页点击 `Synthesize draft` 后 active tab 仍为 `Candidates`。
|
||||
- 草稿评审点击 `Submit` 和 `Approve` 后 active tab 仍为 `Draft review`。
|
||||
- Draft review 实测 `320×568`、`390×844`、`844×390`、`768×1024`、`1365×900` 均无页面级横向越界。
|
||||
- 所有实测视口可见小触控目标数为 `0`。
|
||||
|
||||
## 6. 已修复问题
|
||||
|
||||
| 等级 | 问题 | 修复 |
|
||||
| --- | --- | --- |
|
||||
| P1 | Candidates 和 Draft review 中执行任意异步操作后会回到 Published | tabs 从 `defaultValue` 改为受控 `value`,并同步 URL `tab` 参数 |
|
||||
| P1 | 草稿页 `320px` 下长 draft id、Markdown、评估表格会造成横向撑开风险 | 卡片、Markdown、JSON、长文本统一加容器内断行;eval cases 小屏改卡片 |
|
||||
| P2 | Published 移动端使用宽表格,操作列容易隐藏 | `< md` 改为技能卡片,`>= md` 保留表格 |
|
||||
| P2 | 多个按钮、icon 操作和 Raw details summary 触控高度不足 | 统一提升到 `44px`,icon-only 按钮补充可访问名称 |
|
||||
| P2 | 候选/草稿 tab 无 URL 状态,刷新或分享不能保留子页 | 增加 `?tab=candidates` 和 `?tab=drafts` |
|
||||
|
||||
## 7. 剩余观察项
|
||||
|
||||
- 本轮自动化使用模拟数据覆盖长 id、长 Markdown、候选和草稿状态转换;真实后端极端数据仍需在后续页面联测中持续观察。
|
||||
- 发布高风险草稿的确认分支没有在本轮自动化里实际确认发布,只验证了普通草稿审核路径。
|
||||
144
docs/ui-ux/pages/task-management.md
Normal file
144
docs/ui-ux/pages/task-management.md
Normal file
@ -0,0 +1,144 @@
|
||||
# 主应用:Task 任务页 UI/UX
|
||||
|
||||
## 1. 页面定义
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 页面名称 | Task 任务管理 |
|
||||
| 普通任务路由 | `/tasks` |
|
||||
| 定时任务子页 | `/tasks?tab=scheduled` |
|
||||
| 任务详情路由 | `/tasks/[taskId]` |
|
||||
| 页面实现 | `app-instance/frontend/app/(app)/tasks/page.tsx`、`app-instance/frontend/app/(app)/tasks/[taskId]/page.tsx` |
|
||||
| 关键组件 | `TaskManagementTabs`、`TaskLiveHeader`、`TaskTimeline`、`TaskTimelineCard`、`TaskSideRail`、`TaskAcceptanceControls` |
|
||||
| 核心任务 | 查看任务列表、进入任务详情、删除任务、管理定时任务、查看任务时间线、提交验收反馈 |
|
||||
| 测试状态 | 已修复并复测通过;普通任务、定时任务、任务详情和缺省态在实测视口均无横向越界 |
|
||||
|
||||
## 2. 信息架构与组件层级
|
||||
|
||||
```text
|
||||
AppShell
|
||||
└── main.pt-16
|
||||
├── /tasks
|
||||
│ ├── Page Header
|
||||
│ │ ├── h1 Tasks
|
||||
│ │ ├── 页面说明
|
||||
│ │ └── TaskManagementTabs
|
||||
│ ├── OrdinaryTasks
|
||||
│ │ ├── Loading Card
|
||||
│ │ ├── Empty Card
|
||||
│ │ ├── < xl:OrdinaryTaskCard 列表
|
||||
│ │ └── >= xl:任务表格
|
||||
│ └── ScheduledTasks
|
||||
│ ├── 辅助说明
|
||||
│ ├── 刷新 / 新建定时任务
|
||||
│ ├── AddJobForm
|
||||
│ ├── < xl:ScheduledJobCard 列表
|
||||
│ └── >= xl:定时任务表格
|
||||
└── /tasks/[taskId]
|
||||
├── TaskLiveHeader sticky top: 65px,避让全局头部
|
||||
└── main grid
|
||||
├── 主列
|
||||
│ ├── 删除任务
|
||||
│ └── TaskTimeline
|
||||
│ ├── 任务创建
|
||||
│ ├── 计划 / 工具 / Agent / 产物 / 结果卡片
|
||||
│ ├── Details JSON 展开区
|
||||
│ └── TaskAcceptanceControls
|
||||
└── TaskSideRail
|
||||
├── 任务状态
|
||||
├── 活跃运行
|
||||
├── 最新提醒
|
||||
├── Agent team
|
||||
└── 产物
|
||||
```
|
||||
|
||||
## 3. 布局与响应式规则
|
||||
|
||||
### 普通任务列表
|
||||
|
||||
- 页面外层使用 `max-w-7xl` 和响应式内边距。
|
||||
- `< xl` 视口显示任务卡片,不再显示宽表格;状态、来源、运行数、技能数、更新时间和操作全部在卡片内可见。
|
||||
- `>= xl` 视口显示完整表格,适合批量扫描和横向比较。
|
||||
- 加载状态显示独立 loading card,不会在接口返回前误显示“暂无普通任务”。
|
||||
- 删除任务前使用浏览器确认,取消后不改变列表,确认后从列表移除。
|
||||
|
||||
### 定时任务子页
|
||||
|
||||
- `< xl` 视口显示定时任务卡片,避免将“历史、状态、操作”藏在内部横向滚动表格右侧。
|
||||
- `>= xl` 视口显示表格。
|
||||
- 新建表单以内联卡片展示,包含任务名称、调度类型、调度参数、任务消息、来源会话说明、取消和创建。
|
||||
- 定时任务删除前需要确认,降低误删风险。
|
||||
- Switch、运行、删除、历史、刷新、新建等入口均有可访问名称和 44px 触控目标。
|
||||
|
||||
### 任务详情
|
||||
|
||||
- `TaskLiveHeader` 为 sticky,固定在全局头部下方 `65px`,避免与全局 header 的边框重叠。
|
||||
- 主内容在 `xl` 以下单列排列:时间线在前,侧栏在后。
|
||||
- `xl` 及以上使用 `minmax(0,1fr) + 360px` 双栏:左侧时间线,右侧状态和产物。
|
||||
- 主列、侧栏、卡片和 JSON 区均使用 `min-w-0`、`max-w-full` 和受控换行,防止长 run id、session id、JSON 或产物标题撑破页面。
|
||||
- `Details JSON` 的 summary 命中高度为 44px,支持触控展开。
|
||||
|
||||
## 4. 操作与 UX 逻辑
|
||||
|
||||
| 操作 | 触发方式 | 状态变化与反馈 | UX 目的 | 当前结果 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 切换普通/定时任务 | 点击 TaskManagementTabs | URL 在 `/tasks` 与 `/tasks?tab=scheduled` 间切换,当前 tab 高亮 | 区分用户任务和自动触发任务 | 正常 |
|
||||
| 普通任务进入详情 | 点击“进入任务 / Open task” | 跳转 `/tasks/[taskId]` | 查看时间线、运行过程和验收 | 正常 |
|
||||
| 删除普通任务 | 点击删除按钮后确认 | 取消不变;确认后调用 `DELETE /api/tasks/[id]` 并移除行/卡片 | 防止误删,保持列表干净 | 正常 |
|
||||
| 刷新定时任务 | 点击刷新 | 重新调用定时任务列表接口 | 获取最新调度状态 | 正常 |
|
||||
| 新建定时任务 | 点击新建,填写表单并创建 | 提交 `POST /api/cron/jobs`,成功后收起表单并刷新列表 | 创建自动提醒入口 | 正常 |
|
||||
| 切换定时任务启用 | 点击 Switch | 调用 toggle 接口,刷新列表 | 快速暂停/恢复自动任务 | 正常 |
|
||||
| 立即运行定时任务 | 点击播放按钮 | 调用 run 接口,刷新列表 | 手动触发一次计划任务 | 正常 |
|
||||
| 删除定时任务 | 点击删除按钮后确认 | 取消不变;确认后调用删除接口并刷新列表 | 防止误删自动任务 | 正常 |
|
||||
| 返回任务列表 | 详情页点击 Back to tasks | 返回 `/tasks` | 提供明确退出路径 | 正常 |
|
||||
| 返回对话 | 详情页点击 Chat | 返回 `/` | 回到任务来源工作台 | 正常 |
|
||||
| 跳到验收区 | 点击 Review | 定位到当前结果验收卡片 | 减少长时间线中的查找成本 | 正常 |
|
||||
| 展开 Details JSON | 点击 summary | 展开或收起 JSON 明细 | 为调试和审计保留细节 | 正常 |
|
||||
| 提交修改意见 | 填写验收说明,点击 Needs revision | 调用 `/api/chat/acceptance`,提交 `revise` 和 comment | 将验收反馈记录到当前 run | 正常 |
|
||||
| 下载产物 | 点击 Download | 对内联产物生成下载 | 获取任务输出 | 正常 |
|
||||
| 任务不存在 | 访问缺失 task id | 显示“任务不存在”和返回任务列表 | 明确异常恢复路径 | 正常 |
|
||||
|
||||
## 5. 响应式测试矩阵
|
||||
|
||||
测试日期:2026-06-04。浏览器:Playwright Chromium。环境:本地生产构建 `next build` + `next start` 以及实际 `terminaltest` 实例;API 使用模拟数据,所有布局和交互在真实浏览器中执行。
|
||||
|
||||
| 页面 | 视口 | 横向越界 | 小触控目标 | 关键结论 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 普通任务 | `320×568` | 无 | 0 | 卡片信息完整,操作可见 |
|
||||
| 普通任务 | `390×844` | 无 | 0 | 手机竖屏可直接进入和删除 |
|
||||
| 普通任务 | `844×390` 横屏 | 无 | 0 | 横屏不需要页面横向滚动 |
|
||||
| 普通任务 | `768×1024` | 无 | 0 | 平板单列卡片正常 |
|
||||
| 普通任务 | `1024×768` | 无 | 0 | 窄桌面单列卡片正常 |
|
||||
| 普通任务 | `1365×900` | 无 | 0 | 宽屏表格正常 |
|
||||
| 定时任务 | `390×844` | 无 | 0 | 定时任务卡片完整显示历史、状态和操作 |
|
||||
| 任务详情 | `320×568` | 无 | 0 | 长 session id、JSON、验收区均未撑破页面 |
|
||||
| 任务详情 | `390×844` | 无 | 0 | sticky 头部避让全局头部 |
|
||||
| 任务详情 | `768×1024` | 无 | 0 | 单列详情和侧栏顺序正常 |
|
||||
| 任务详情 | `1365×900` | 无 | 0 | 双栏详情正常,侧栏产物可下载 |
|
||||
|
||||
### 关键量化证据
|
||||
|
||||
- 普通任务所有实测视口 `scrollWidth === clientWidth`。
|
||||
- 定时任务 `390×844` 视口 `scrollWidth === clientWidth`。
|
||||
- 任务详情 `320×568`、`390×844`、`768×1024`、`1365×900` 均无页面级横向越界。
|
||||
- 任务详情 sticky 头部实测 `globalBottom=65`、`taskTop=65`、`overlapsGlobal=false`。
|
||||
- 最终复测中所有检查视口可见小触控目标数为 `0`。
|
||||
- 部署到 `terminaltest` 后,同一套 Task QA 自动化用例 `14 passed`。
|
||||
|
||||
## 6. 已修复问题
|
||||
|
||||
| 等级 | 问题 | 修复 |
|
||||
| --- | --- | --- |
|
||||
| P1 | 普通任务和定时任务在手机端只露出表格左侧,核心操作藏在内部横向滚动区 | `< xl` 改为卡片布局,宽屏保留表格 |
|
||||
| P1 | 任务详情在 `320px` 和 `390px` 被时间线内容撑到约 `626px`,产生页面级横向越界 | 主列、卡片、侧栏加入 `min-w-0/max-w-full`,控制长文本和 JSON |
|
||||
| P1 | 任务详情 sticky 头部滚动后与全局固定头部重叠 | sticky top 改为 `65px`,避让全局 header 和边框 |
|
||||
| P1 | React Strict Mode 下任务详情 `mountedRef` 清理后不恢复,详情可能停留在“任务不存在/加载中” | effect 挂载时重置 `mountedRef.current = true` |
|
||||
| P2 | 定时任务删除无确认 | 删除前增加确认弹窗 |
|
||||
| P2 | 定时任务播放/删除图标按钮无可访问名称且目标偏小 | 补充 `aria-label/title`,扩大到 44px |
|
||||
| P2 | tabs、summary、switch 等控件命中高度不足 44px | 统一提高到 44px 触控目标 |
|
||||
| P2 | 普通任务首次加载可能误显示空状态 | 增加 loading 状态 |
|
||||
|
||||
## 7. 剩余观察项
|
||||
|
||||
- 本轮自动化使用模拟 API 数据验证布局和交互;真实后端数据的极端状态仍需在后续页面联测中持续观察。
|
||||
- Playwright 报告中存在一次 404 控制台日志,来源为浏览器资源请求,不影响 Task 页面核心 API 与交互。
|
||||
@ -13,7 +13,9 @@ def create_app(*, provider: ConnectorProvider, api_token: str) -> FastAPI:
|
||||
app = FastAPI(title="External Connector")
|
||||
|
||||
def require_auth(authorization: str | None) -> None:
|
||||
if api_token and authorization != f"Bearer {api_token}":
|
||||
if not api_token:
|
||||
raise HTTPException(status_code=503, detail="Connector API token is not configured")
|
||||
if authorization != f"Bearer {api_token}":
|
||||
raise HTTPException(status_code=401, detail="Invalid connector token")
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
216
external-connector/external_connector/node/feishu_event_utils.js
Normal file
216
external-connector/external_connector/node/feishu_event_utils.js
Normal file
@ -0,0 +1,216 @@
|
||||
function bridgeEventFromFeishu(data, env) {
|
||||
const message = objectValue(data.message);
|
||||
const sender = objectValue(data.sender);
|
||||
const senderId = objectValue(sender.sender_id);
|
||||
const isGroup = message.chat_type === "group";
|
||||
const peerId = isGroup
|
||||
? stringValue(message.chat_id || "")
|
||||
: stringValue(senderId.open_id || senderId.user_id || "");
|
||||
const userId = stringValue(senderId.open_id || senderId.user_id || "");
|
||||
const messageId = stringValue(message.message_id || randomId());
|
||||
const eventId = stringValue(data.event_id || data.eventId || `${env.channelId}:${messageId}`);
|
||||
return {
|
||||
eventId,
|
||||
timestamp: new Date().toISOString(),
|
||||
deliveryAttempt: 1,
|
||||
connectionId: env.connectionId,
|
||||
channelId: env.channelId,
|
||||
kind: "feishu",
|
||||
accountId: env.accountId,
|
||||
peerId,
|
||||
peerType: isGroup ? "group" : "dm",
|
||||
userId,
|
||||
threadId: stringValue(message.chat_id || "") || null,
|
||||
messageId,
|
||||
messageType: stringValue(message.message_type || "text"),
|
||||
content: extractText(message),
|
||||
metadata: {
|
||||
chatId: message.chat_id || null,
|
||||
rawMessageType: message.message_type || null,
|
||||
senderType: sender.sender_type || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function bridgeEventFromNormalizedMessage(message, env, options = {}) {
|
||||
const maxMessageChars = positiveInt(options.maxMessageChars, 20000);
|
||||
const content = normalizedContent(message).trim();
|
||||
if (!content || content.length > maxMessageChars) {
|
||||
return null;
|
||||
}
|
||||
const isGroup = message.chatType === "group";
|
||||
const messageId = stringValue(message.messageId || randomId());
|
||||
const chatId = stringValue(message.chatId || "");
|
||||
const senderId = stringValue(message.senderId || "");
|
||||
const eventId = stringValue(rawEventId(message.raw) || `${env.channelId}:${messageId}`);
|
||||
return {
|
||||
eventId,
|
||||
timestamp: new Date().toISOString(),
|
||||
deliveryAttempt: 1,
|
||||
connectionId: env.connectionId,
|
||||
channelId: env.channelId,
|
||||
kind: "feishu",
|
||||
accountId: env.accountId,
|
||||
peerId: isGroup ? chatId : senderId,
|
||||
peerType: isGroup ? "group" : "dm",
|
||||
userId: senderId,
|
||||
threadId: chatId || null,
|
||||
messageId,
|
||||
messageType: stringValue(message.rawContentType || "text"),
|
||||
content,
|
||||
metadata: {
|
||||
chatId: chatId || null,
|
||||
rawMessageType: message.rawContentType || null,
|
||||
senderName: message.senderName || null,
|
||||
mentions: Array.isArray(message.mentions) ? message.mentions : [],
|
||||
mentionAll: Boolean(message.mentionAll),
|
||||
mentionedBot: Boolean(message.mentionedBot),
|
||||
resources: Array.isArray(message.resources) ? message.resources : [],
|
||||
createTime: message.createTime || null,
|
||||
rootId: message.rootId || null,
|
||||
threadId: message.threadId || null,
|
||||
replyToMessageId: message.replyToMessageId || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildChannelOptions({ appId, appSecret, domain, policy }) {
|
||||
const resolvedPolicy = policy || parsePolicyEnv(process.env);
|
||||
return {
|
||||
appId,
|
||||
appSecret,
|
||||
domain,
|
||||
includeRawEvent: true,
|
||||
source: "beaver",
|
||||
handshakeTimeoutMs: 15000,
|
||||
wsConfig: { pingTimeout: 10 },
|
||||
policy: {
|
||||
requireMention: resolvedPolicy.requireMentionInGroups,
|
||||
respondToMentionAll: resolvedPolicy.respondToMentionAll,
|
||||
dmMode: resolvedPolicy.dmMode,
|
||||
dmAllowlist: resolvedPolicy.allowFrom,
|
||||
groupAllowlist: resolvedPolicy.groupAllowFrom,
|
||||
},
|
||||
safety: {
|
||||
chatQueue: { enabled: true },
|
||||
staleMessageWindowMs: positiveInt(resolvedPolicy.staleMessageWindowMs, 10 * 60 * 1000),
|
||||
batch: {
|
||||
text: {
|
||||
delayMs: positiveInt(resolvedPolicy.textBatchDelayMs, 0),
|
||||
maxMessages: positiveInt(resolvedPolicy.textBatchMaxMessages, 10),
|
||||
maxChars: positiveInt(resolvedPolicy.textBatchMaxChars, resolvedPolicy.maxMessageChars),
|
||||
},
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
textChunkLimit: resolvedPolicy.maxMessageChars,
|
||||
retry: { maxAttempts: 3, baseDelayMs: 500 },
|
||||
ssrfGuard: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parsePolicyEnv(env) {
|
||||
return {
|
||||
requireMentionInGroups: envBool(env.FEISHU_REQUIRE_MENTION_IN_GROUPS, true),
|
||||
respondToMentionAll: envBool(env.FEISHU_RESPOND_TO_MENTION_ALL, false),
|
||||
dmMode: stringValue(env.FEISHU_DM_MODE || "open") || "open",
|
||||
allowFrom: envList(env.FEISHU_ALLOW_FROM || env.FEISHU_DM_ALLOW_FROM || ""),
|
||||
groupAllowFrom: envList(env.FEISHU_GROUP_ALLOW_FROM || ""),
|
||||
maxMessageChars: positiveInt(env.FEISHU_MAX_MESSAGE_CHARS, 20000),
|
||||
textBatchDelayMs: positiveInt(env.FEISHU_TEXT_BATCH_DELAY_MS, 0),
|
||||
textBatchMaxMessages: positiveInt(env.FEISHU_TEXT_BATCH_MAX_MESSAGES, 10),
|
||||
textBatchMaxChars: positiveInt(env.FEISHU_TEXT_BATCH_MAX_CHARS, 20000),
|
||||
staleMessageWindowMs: positiveInt(env.FEISHU_STALE_MESSAGE_WINDOW_MS, 10 * 60 * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
function ignoreReason(data) {
|
||||
const sender = objectValue(data.sender);
|
||||
const senderType = stringValue(sender.sender_type || "").toLowerCase();
|
||||
if (senderType && senderType !== "user") {
|
||||
return `sender_type:${senderType}`;
|
||||
}
|
||||
const content = extractText(objectValue(data.message)).trim();
|
||||
if (content.startsWith("/feishu")) {
|
||||
return "feishu_command";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizedContent(message) {
|
||||
const parts = [stringValue(message.content || "")];
|
||||
const resources = Array.isArray(message.resources) ? message.resources : [];
|
||||
for (const resource of resources) {
|
||||
if (!resource || typeof resource !== "object") {
|
||||
continue;
|
||||
}
|
||||
const type = stringValue(resource.type || "file");
|
||||
const fileName = stringValue(resource.fileName || "");
|
||||
parts.push(`[${type}${fileName ? `: ${fileName}` : ""}]`);
|
||||
}
|
||||
return parts.filter((part) => part.trim()).join("\n");
|
||||
}
|
||||
|
||||
function rawEventId(raw) {
|
||||
const rawObject = objectValue(raw);
|
||||
const header = objectValue(rawObject.header);
|
||||
return rawObject.event_id || rawObject.eventId || header.event_id || header.eventId || "";
|
||||
}
|
||||
|
||||
function extractText(message) {
|
||||
const content = message.content;
|
||||
if (typeof content !== "string") {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (parsed && parsed.text != null) {
|
||||
return String(parsed.text);
|
||||
}
|
||||
} catch (_error) {
|
||||
return content;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function envList(value) {
|
||||
return stringValue(value)
|
||||
.replace(/\n/g, ",")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function envBool(value, defaultValue) {
|
||||
if (value == null || value === "") {
|
||||
return defaultValue;
|
||||
}
|
||||
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
|
||||
}
|
||||
|
||||
function positiveInt(value, defaultValue) {
|
||||
const number = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isFinite(number) && number > 0 ? number : defaultValue;
|
||||
}
|
||||
|
||||
function objectValue(value) {
|
||||
return value && typeof value === "object" ? value : {};
|
||||
}
|
||||
|
||||
function stringValue(value) {
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function randomId() {
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
bridgeEventFromFeishu,
|
||||
bridgeEventFromNormalizedMessage,
|
||||
buildChannelOptions,
|
||||
ignoreReason,
|
||||
extractText,
|
||||
parsePolicyEnv,
|
||||
};
|
||||
@ -1,4 +1,9 @@
|
||||
const Lark = require("@larksuiteoapi/node-sdk");
|
||||
const {
|
||||
bridgeEventFromNormalizedMessage,
|
||||
buildChannelOptions,
|
||||
parsePolicyEnv,
|
||||
} = require("./feishu_event_utils");
|
||||
|
||||
const appId = requireEnv("FEISHU_APP_ID");
|
||||
const appSecret = requireEnv("FEISHU_APP_SECRET");
|
||||
@ -10,99 +15,78 @@ const bridgeToken = requireEnv("BEAVER_BRIDGE_TOKEN");
|
||||
const domain = (process.env.FEISHU_DOMAIN || "feishu").toLowerCase() === "lark"
|
||||
? Lark.Domain.Lark
|
||||
: Lark.Domain.Feishu;
|
||||
const policy = parsePolicyEnv(process.env);
|
||||
const env = { connectionId, channelId, accountId };
|
||||
|
||||
const wsClient = new Lark.WSClient({
|
||||
appId,
|
||||
appSecret,
|
||||
domain,
|
||||
loggerLevel: Lark.LoggerLevel.info,
|
||||
onReady: () => log("feishu_ws_ready", {}),
|
||||
onError: (error) => log("feishu_ws_error", { error: redact(String(error && error.message ? error.message : error)) }),
|
||||
onReconnecting: () => log("feishu_ws_reconnecting", {}),
|
||||
onReconnected: () => log("feishu_ws_reconnected", {}),
|
||||
handshakeTimeoutMs: 15000,
|
||||
wsConfig: { pingTimeout: 10 },
|
||||
const channel = Lark.createLarkChannel(buildChannelOptions({ appId, appSecret, domain, policy }));
|
||||
channel.on({
|
||||
message: async (message) => {
|
||||
const event = bridgeEventFromNormalizedMessage(message, env, { maxMessageChars: policy.maxMessageChars });
|
||||
if (!event) {
|
||||
log("feishu_inbound_ignored", {
|
||||
connectionId,
|
||||
messageId: message.messageId,
|
||||
reason: "empty_or_oversized",
|
||||
});
|
||||
|
||||
const dispatcher = new Lark.EventDispatcher({}).register({
|
||||
"im.message.receive_v1": async (data) => {
|
||||
const event = bridgeEventFromFeishu(data);
|
||||
return;
|
||||
}
|
||||
log("feishu_inbound_message", {
|
||||
connectionId,
|
||||
eventId: event.eventId,
|
||||
messageId: event.messageId,
|
||||
peerId: event.peerId,
|
||||
peerType: event.peerType,
|
||||
textLength: event.content.length,
|
||||
});
|
||||
await postJson(`${bridgeBaseUrl}/api/channel-connector-bridge/events`, event);
|
||||
},
|
||||
reject: (event) => {
|
||||
log("feishu_inbound_ignored", {
|
||||
connectionId,
|
||||
messageId: event.messageId,
|
||||
peerId: event.chatId,
|
||||
userId: event.senderId,
|
||||
reason: event.reason,
|
||||
});
|
||||
},
|
||||
error: (error) => {
|
||||
log("feishu_ws_error", {
|
||||
connectionId,
|
||||
code: error.code || "unknown",
|
||||
error: redact(String(error && error.message ? error.message : error)),
|
||||
});
|
||||
},
|
||||
reconnecting: () => log("feishu_ws_reconnecting", { connectionId }),
|
||||
reconnected: () => log("feishu_ws_reconnected", { connectionId }),
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
wsClient.close({ force: true });
|
||||
process.exit(0);
|
||||
channel.disconnect().finally(() => process.exit(0));
|
||||
});
|
||||
process.on("SIGINT", () => {
|
||||
wsClient.close({ force: true });
|
||||
process.exit(0);
|
||||
channel.disconnect().finally(() => process.exit(0));
|
||||
});
|
||||
|
||||
wsClient.start({ eventDispatcher: dispatcher }).catch((error) => {
|
||||
channel.connect().then(() => {
|
||||
log("feishu_ws_ready", {
|
||||
connectionId,
|
||||
requireMentionInGroups: policy.requireMentionInGroups,
|
||||
dmMode: policy.dmMode,
|
||||
groupAllowlistSize: policy.groupAllowFrom.length,
|
||||
dmAllowlistSize: policy.allowFrom.length,
|
||||
});
|
||||
}).catch((error) => {
|
||||
log("feishu_ws_start_failed", { error: redact(String(error && error.message ? error.message : error)) });
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
if (typeof wsClient.getConnectionStatus === "function") {
|
||||
log("feishu_ws_status", wsClient.getConnectionStatus());
|
||||
const status = channel.getConnectionStatus();
|
||||
if (status) {
|
||||
log("feishu_ws_status", { connectionId, ...status });
|
||||
}
|
||||
}, 60000).unref();
|
||||
|
||||
function bridgeEventFromFeishu(data) {
|
||||
const message = objectValue(data.message);
|
||||
const sender = objectValue(data.sender);
|
||||
const senderId = objectValue(sender.sender_id);
|
||||
const peerId = stringValue(senderId.open_id || senderId.user_id || "");
|
||||
const messageId = stringValue(message.message_id || randomId());
|
||||
const eventId = stringValue(data.event_id || data.eventId || `${channelId}:${messageId}`);
|
||||
return {
|
||||
eventId,
|
||||
timestamp: new Date().toISOString(),
|
||||
deliveryAttempt: 1,
|
||||
connectionId,
|
||||
channelId,
|
||||
kind: "feishu",
|
||||
accountId,
|
||||
peerId,
|
||||
peerType: message.chat_type === "group" ? "group" : "dm",
|
||||
userId: peerId,
|
||||
threadId: stringValue(message.chat_id || "") || null,
|
||||
messageId,
|
||||
messageType: stringValue(message.message_type || "text"),
|
||||
content: extractText(message),
|
||||
metadata: {
|
||||
chatId: message.chat_id || null,
|
||||
rawMessageType: message.message_type || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extractText(message) {
|
||||
const content = message.content;
|
||||
if (typeof content !== "string") {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (parsed && parsed.text != null) {
|
||||
return String(parsed.text);
|
||||
}
|
||||
} catch (_error) {
|
||||
return content;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
async function postJson(url, payload) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
@ -126,18 +110,6 @@ function requireEnv(name) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function objectValue(value) {
|
||||
return value && typeof value === "object" ? value : {};
|
||||
}
|
||||
|
||||
function stringValue(value) {
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function randomId() {
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function log(event, fields) {
|
||||
console.log(JSON.stringify({ event, ...fields }));
|
||||
}
|
||||
|
||||
@ -89,6 +89,7 @@ class FeishuBotProvider:
|
||||
verification_token = str(options.get("verificationToken") or options.get("verification_token") or "").strip()
|
||||
mode = str(options.get("mode") or "create").strip().lower()
|
||||
domain = _domain(options)
|
||||
metadata.update(_policy_metadata(options))
|
||||
if not app_id or not app_secret:
|
||||
if mode != "link":
|
||||
return self._start_registration_session(session, metadata=metadata, domain=domain)
|
||||
@ -157,34 +158,44 @@ class FeishuBotProvider:
|
||||
target = dict(payload.get("target") or {})
|
||||
metadata = dict(session.metadata)
|
||||
api_base = _open_api_base_url(str(metadata.get("domain") or "feishu"), self.api_base_url)
|
||||
peer_type = str(target.get("peerType") or "dm")
|
||||
receive_id_type = "chat_id" if peer_type == "group" else "open_id"
|
||||
receive_id = str(target.get("threadId") or target.get("peerId") or "") if receive_id_type == "chat_id" else str(target.get("peerId") or "")
|
||||
chunks = _text_chunks(str(payload.get("content") or ""), _positive_int(metadata.get("maxMessageChars"), default=20000))
|
||||
provider_message_ids: list[str] = []
|
||||
try:
|
||||
for chunk in chunks:
|
||||
response = self.http.post(
|
||||
f"{api_base}/open-apis/im/v1/messages?receive_id_type=open_id",
|
||||
f"{api_base}/open-apis/im/v1/messages?receive_id_type={receive_id_type}",
|
||||
json={
|
||||
"receive_id": str(target.get("peerId") or ""),
|
||||
"receive_id": receive_id,
|
||||
"msg_type": "text",
|
||||
"content": json.dumps({"text": str(payload.get("content") or "")}, ensure_ascii=False),
|
||||
"content": json.dumps({"text": chunk}, ensure_ascii=False),
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
timeout=20,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
self.store.fail_send(begin.dedupe_key, error=error)
|
||||
return {"ok": False, "error": error, "httpStatus": 502}
|
||||
data = dict(response.json())
|
||||
if int(data.get("code") or 0) != 0:
|
||||
error = str(data.get("msg") or data)
|
||||
self.store.fail_send(begin.dedupe_key, error=error)
|
||||
return {"ok": False, "error": error, "httpStatus": 502}
|
||||
provider_message_id = str((data.get("data") or {}).get("message_id") or f"feishu_{payload['requestId']}")
|
||||
provider_message_ids.append(str((data.get("data") or {}).get("message_id") or f"feishu_{payload['requestId']}"))
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
self.store.fail_send(begin.dedupe_key, error=error)
|
||||
return {"ok": False, "error": error, "httpStatus": 502}
|
||||
provider_message_id = ",".join(provider_message_ids) or f"feishu_{payload['requestId']}"
|
||||
self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id)
|
||||
return {"ok": True, "providerMessageId": provider_message_id}
|
||||
|
||||
def handle_event(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
challenge = payload.get("challenge")
|
||||
if challenge:
|
||||
token = str(payload.get("token") or (payload.get("header") or {}).get("token") or "")
|
||||
if not token or self._session_for_verification_token(token) is None:
|
||||
return {"ok": False, "error": "verification token is required", "httpStatus": 401}
|
||||
return {"challenge": challenge}
|
||||
header = dict(payload.get("header") or {})
|
||||
event = dict(payload.get("event") or {})
|
||||
@ -192,9 +203,14 @@ class FeishuBotProvider:
|
||||
session = self._session_for_app_id(app_id)
|
||||
expected_token = str(session.metadata.get("verificationToken") or "")
|
||||
received_token = str(header.get("token") or payload.get("token") or "")
|
||||
if expected_token and received_token != expected_token:
|
||||
if not expected_token or received_token != expected_token:
|
||||
return {"ok": False, "error": "invalid verification token", "httpStatus": 401}
|
||||
ignored = _ignore_reason(event)
|
||||
if ignored:
|
||||
return {"ok": True, "ignored": ignored}
|
||||
bridge_event = _bridge_event_from_feishu(session, header, event)
|
||||
if bridge_event is None:
|
||||
return {"ok": True, "ignored": "empty_or_oversized"}
|
||||
self.bridge_post(
|
||||
f"{self.bridge_base_url}/api/channel-connector-bridge/events",
|
||||
bridge_event,
|
||||
@ -234,6 +250,18 @@ class FeishuBotProvider:
|
||||
return session
|
||||
raise KeyError(app_id)
|
||||
|
||||
def _session_for_verification_token(self, token: str) -> ConnectorSessionState | None:
|
||||
sessions = self.store.list_sessions()
|
||||
for session in sorted(sessions, key=lambda item: item.updated_at, reverse=True):
|
||||
if (
|
||||
session.kind == "feishu"
|
||||
and session.status == "connected"
|
||||
and token
|
||||
and session.metadata.get("verificationToken") == token
|
||||
):
|
||||
return session
|
||||
return None
|
||||
|
||||
def _default_bridge_post(self, url: str, payload: dict[str, Any], headers: dict[str, str]) -> None:
|
||||
response = self.http.post(url, json=payload, headers=headers, timeout=20)
|
||||
response.raise_for_status()
|
||||
@ -415,6 +443,15 @@ class FeishuBotProvider:
|
||||
"FEISHU_CONNECTION_ID": session.connection_id,
|
||||
"FEISHU_CHANNEL_ID": session.channel_id,
|
||||
"FEISHU_ACCOUNT_ID": str(session.account_id or ""),
|
||||
"FEISHU_REQUIRE_MENTION_IN_GROUPS": _env_bool(metadata.get("requireMentionInGroups"), default=True),
|
||||
"FEISHU_RESPOND_TO_MENTION_ALL": _env_bool(metadata.get("respondToMentionAll"), default=False),
|
||||
"FEISHU_DM_MODE": str(metadata.get("dmMode") or "open"),
|
||||
"FEISHU_ALLOW_FROM": ",".join(_string_list(metadata.get("allowFrom"))),
|
||||
"FEISHU_GROUP_ALLOW_FROM": ",".join(_string_list(metadata.get("groupAllowFrom"))),
|
||||
"FEISHU_MAX_MESSAGE_CHARS": str(_positive_int(metadata.get("maxMessageChars"), default=20000)),
|
||||
"FEISHU_TEXT_BATCH_DELAY_MS": str(_positive_int(metadata.get("textBatchDelayMs"), default=0)),
|
||||
"FEISHU_TEXT_BATCH_MAX_MESSAGES": str(_positive_int(metadata.get("textBatchMaxMessages"), default=10)),
|
||||
"FEISHU_TEXT_BATCH_MAX_CHARS": str(_positive_int(metadata.get("textBatchMaxChars"), default=20000)),
|
||||
"BEAVER_BRIDGE_BASE_URL": self.bridge_base_url,
|
||||
"BEAVER_BRIDGE_TOKEN": self.bridge_token,
|
||||
}
|
||||
@ -422,13 +459,20 @@ class FeishuBotProvider:
|
||||
return subprocess.Popen(["node", str(script)], env=env, cwd=str(script.parent))
|
||||
|
||||
|
||||
def _bridge_event_from_feishu(session: ConnectorSessionState, header: dict[str, Any], event: dict[str, Any]) -> dict[str, Any]:
|
||||
def _bridge_event_from_feishu(session: ConnectorSessionState, header: dict[str, Any], event: dict[str, Any]) -> dict[str, Any] | None:
|
||||
message = dict(event.get("message") or {})
|
||||
sender = dict(event.get("sender") or {})
|
||||
sender_id = dict(sender.get("sender_id") or {})
|
||||
peer_id = str(sender_id.get("open_id") or sender_id.get("user_id") or "")
|
||||
is_group = message.get("chat_type") == "group"
|
||||
sender_open_id = str(sender_id.get("open_id") or sender_id.get("user_id") or "")
|
||||
chat_id = str(message.get("chat_id") or "")
|
||||
peer_id = chat_id if is_group else sender_open_id
|
||||
message_id = str(message.get("message_id") or uuid4().hex)
|
||||
event_id = str(header.get("event_id") or f"{session.channel_id}:{message_id}")
|
||||
content = _extract_text(message).strip()
|
||||
max_chars = _positive_int(session.metadata.get("maxMessageChars"), default=20000)
|
||||
if not content or len(content) > max_chars:
|
||||
return None
|
||||
return {
|
||||
"eventId": event_id,
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
@ -438,13 +482,18 @@ def _bridge_event_from_feishu(session: ConnectorSessionState, header: dict[str,
|
||||
"kind": "feishu",
|
||||
"accountId": session.account_id,
|
||||
"peerId": peer_id,
|
||||
"peerType": "group" if message.get("chat_type") == "group" else "dm",
|
||||
"userId": peer_id,
|
||||
"threadId": str(message.get("chat_id") or "") or None,
|
||||
"peerType": "group" if is_group else "dm",
|
||||
"userId": sender_open_id,
|
||||
"threadId": chat_id or None,
|
||||
"messageId": message_id,
|
||||
"messageType": str(message.get("message_type") or "text"),
|
||||
"content": _extract_text(message),
|
||||
"metadata": {"chatId": message.get("chat_id"), "rawMessageType": message.get("message_type")},
|
||||
"content": content,
|
||||
"metadata": {
|
||||
"chatId": message.get("chat_id"),
|
||||
"rawMessageType": message.get("message_type"),
|
||||
"senderType": sender.get("sender_type"),
|
||||
"mentions": message.get("mentions") if isinstance(message.get("mentions"), list) else [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -480,6 +529,97 @@ def _extract_text(message: dict[str, Any]) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _text_chunks(text: str, max_chars: int) -> list[str]:
|
||||
cleaned = str(text or "")
|
||||
if not cleaned:
|
||||
return [""]
|
||||
size = max(1, int(max_chars))
|
||||
return [cleaned[index : index + size] for index in range(0, len(cleaned), size)]
|
||||
|
||||
|
||||
def _ignore_reason(event: dict[str, Any]) -> str:
|
||||
sender = dict(event.get("sender") or {})
|
||||
sender_type = str(sender.get("sender_type") or "").strip().lower()
|
||||
if sender_type and sender_type != "user":
|
||||
return f"sender_type:{sender_type}"
|
||||
message = dict(event.get("message") or {})
|
||||
content = _extract_text(message).strip()
|
||||
if content.startswith("/feishu"):
|
||||
return "feishu_command"
|
||||
return ""
|
||||
|
||||
|
||||
def _policy_metadata(options: dict[str, Any]) -> dict[str, Any]:
|
||||
metadata: dict[str, Any] = {}
|
||||
for key in ("allowFrom", "allow_from"):
|
||||
items = _string_list(options.get(key))
|
||||
if items:
|
||||
metadata["allowFrom"] = items
|
||||
break
|
||||
for key in ("groupAllowFrom", "group_allow_from"):
|
||||
items = _string_list(options.get(key))
|
||||
if items:
|
||||
metadata["groupAllowFrom"] = items
|
||||
break
|
||||
if "requireMentionInGroups" in options or "require_mention_in_groups" in options:
|
||||
metadata["requireMentionInGroups"] = _bool(options.get("requireMentionInGroups", options.get("require_mention_in_groups")))
|
||||
else:
|
||||
metadata["requireMentionInGroups"] = True
|
||||
if "respondToMentionAll" in options or "respond_to_mention_all" in options:
|
||||
metadata["respondToMentionAll"] = _bool(options.get("respondToMentionAll", options.get("respond_to_mention_all")))
|
||||
dm_mode = str(options.get("dmMode") or options.get("dm_mode") or "open").strip()
|
||||
metadata["dmMode"] = dm_mode if dm_mode in {"open", "allowlist", "pair", "disabled"} else "open"
|
||||
for key, metadata_key, default in (
|
||||
("maxMessageChars", "maxMessageChars", 20000),
|
||||
("textBatchDelayMs", "textBatchDelayMs", 0),
|
||||
("textBatchMaxMessages", "textBatchMaxMessages", 10),
|
||||
("textBatchMaxChars", "textBatchMaxChars", 20000),
|
||||
):
|
||||
alt_key = _camel_to_snake(key)
|
||||
if key in options or alt_key in options:
|
||||
metadata[metadata_key] = _positive_int(options.get(key, options.get(alt_key)), default=default)
|
||||
return metadata
|
||||
|
||||
|
||||
def _string_list(value: Any) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
raw_items = value.replace("\n", ",").split(",")
|
||||
elif isinstance(value, list):
|
||||
raw_items = value
|
||||
else:
|
||||
raw_items = []
|
||||
return [str(item).strip() for item in raw_items if str(item).strip()]
|
||||
|
||||
|
||||
def _bool(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _positive_int(value: Any, *, default: int) -> int:
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return number if number > 0 else default
|
||||
|
||||
|
||||
def _env_bool(value: Any, *, default: bool) -> str:
|
||||
if value is None:
|
||||
return "true" if default else "false"
|
||||
return "true" if _bool(value) else "false"
|
||||
|
||||
|
||||
def _camel_to_snake(value: str) -> str:
|
||||
result: list[str] = []
|
||||
for char in value:
|
||||
if char.isupper() and result:
|
||||
result.append("_")
|
||||
result.append(char.lower())
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def _domain(options: dict[str, Any]) -> str:
|
||||
domain = str(options.get("domain") or "feishu").strip().lower()
|
||||
return "lark" if domain == "lark" else "feishu"
|
||||
|
||||
@ -45,6 +45,7 @@ class WeixinIlinkProvider:
|
||||
self.start_receivers = start_receivers
|
||||
self._receiver_stops: dict[str, threading.Event] = {}
|
||||
self._receiver_lock = threading.Lock()
|
||||
self._start_existing_connected_receivers()
|
||||
|
||||
def connectors(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
@ -303,13 +304,45 @@ class WeixinIlinkProvider:
|
||||
self._receiver_stops[session.connection_id] = stop
|
||||
thread = threading.Thread(target=self._receiver_loop, args=(session.connection_id, stop), daemon=True)
|
||||
thread.start()
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "weixin_receiver_started",
|
||||
"connectionId": session.connection_id,
|
||||
"channelId": session.channel_id,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
return None
|
||||
|
||||
def _start_existing_connected_receivers(self) -> None:
|
||||
if not self.start_receivers:
|
||||
return None
|
||||
for session in self.store.list_sessions():
|
||||
if session.kind != "weixin" or session.status != "connected":
|
||||
continue
|
||||
if _has_connection_material(session):
|
||||
self._ensure_receiver(session)
|
||||
return None
|
||||
|
||||
def _receiver_loop(self, connection_id: str, stop: threading.Event) -> None:
|
||||
while not stop.is_set():
|
||||
try:
|
||||
self.poll_once(connection_id)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "weixin_receiver_error",
|
||||
"connectionId": connection_id,
|
||||
"error": str(exc)[:300],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
time.sleep(5)
|
||||
stop.wait(1)
|
||||
|
||||
|
||||
189
external-connector/tests/node/feishu_event_utils.test.js
Normal file
189
external-connector/tests/node/feishu_event_utils.test.js
Normal file
@ -0,0 +1,189 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
bridgeEventFromFeishu,
|
||||
bridgeEventFromNormalizedMessage,
|
||||
buildChannelOptions,
|
||||
ignoreReason,
|
||||
parsePolicyEnv,
|
||||
} = require("../../external_connector/node/feishu_event_utils");
|
||||
|
||||
test("ignores Feishu app or bot sender events", () => {
|
||||
assert.equal(ignoreReason({ sender: { sender_type: "app" }, message: { content: "{\"text\":\"hello\"}" } }), "sender_type:app");
|
||||
assert.equal(ignoreReason({ sender: { sender_type: "bot" }, message: { content: "{\"text\":\"hello\"}" } }), "sender_type:bot");
|
||||
});
|
||||
|
||||
test("ignores Feishu slash commands intended for the platform integration", () => {
|
||||
assert.equal(ignoreReason({ sender: { sender_type: "user" }, message: { content: "{\"text\":\"/feishu start\"}" } }), "feishu_command");
|
||||
});
|
||||
|
||||
test("keeps user messages and records sender type metadata", () => {
|
||||
const event = bridgeEventFromFeishu(
|
||||
{
|
||||
event_id: "evt_1",
|
||||
sender: { sender_type: "user", sender_id: { open_id: "ou_user" } },
|
||||
message: {
|
||||
message_id: "om_1",
|
||||
chat_id: "oc_1",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: "{\"text\":\"hello\"}",
|
||||
},
|
||||
},
|
||||
{ connectionId: "conn_1", channelId: "feishu-main", accountId: "feishu:cli_1" },
|
||||
);
|
||||
|
||||
assert.equal(ignoreReason({ sender: { sender_type: "user" }, message: { content: "{\"text\":\"hello\"}" } }), "");
|
||||
assert.equal(event.content, "hello");
|
||||
assert.equal(event.peerId, "ou_user");
|
||||
assert.equal(event.metadata.senderType, "user");
|
||||
});
|
||||
|
||||
test("uses chat id as peer id for group messages", () => {
|
||||
const event = bridgeEventFromFeishu(
|
||||
{
|
||||
event_id: "evt_1",
|
||||
sender: { sender_type: "user", sender_id: { open_id: "ou_user" } },
|
||||
message: {
|
||||
message_id: "om_1",
|
||||
chat_id: "oc_group",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: "{\"text\":\"@bot hello\"}",
|
||||
},
|
||||
},
|
||||
{ connectionId: "conn_1", channelId: "feishu-main", accountId: "feishu:cli_1" },
|
||||
);
|
||||
|
||||
assert.equal(event.peerType, "group");
|
||||
assert.equal(event.peerId, "oc_group");
|
||||
assert.equal(event.userId, "ou_user");
|
||||
});
|
||||
|
||||
test("builds SDK channel options from explicit Feishu policy environment", () => {
|
||||
const policy = parsePolicyEnv({
|
||||
FEISHU_REQUIRE_MENTION_IN_GROUPS: "false",
|
||||
FEISHU_RESPOND_TO_MENTION_ALL: "true",
|
||||
FEISHU_DM_MODE: "allowlist",
|
||||
FEISHU_ALLOW_FROM: "ou_1, ou_2",
|
||||
FEISHU_GROUP_ALLOW_FROM: "oc_1\noc_2",
|
||||
FEISHU_MAX_MESSAGE_CHARS: "1234",
|
||||
FEISHU_TEXT_BATCH_DELAY_MS: "250",
|
||||
FEISHU_TEXT_BATCH_MAX_MESSAGES: "5",
|
||||
FEISHU_TEXT_BATCH_MAX_CHARS: "2048",
|
||||
});
|
||||
const options = buildChannelOptions({
|
||||
appId: "cli_1",
|
||||
appSecret: "secret",
|
||||
domain: "feishu",
|
||||
policy,
|
||||
});
|
||||
|
||||
assert.equal(options.policy.requireMention, false);
|
||||
assert.equal(options.policy.respondToMentionAll, true);
|
||||
assert.equal(options.policy.dmMode, "allowlist");
|
||||
assert.deepEqual(options.policy.dmAllowlist, ["ou_1", "ou_2"]);
|
||||
assert.deepEqual(options.policy.groupAllowlist, ["oc_1", "oc_2"]);
|
||||
assert.equal(options.outbound.textChunkLimit, 1234);
|
||||
assert.equal(options.safety.batch.text.delayMs, 250);
|
||||
assert.equal(options.safety.batch.text.maxMessages, 5);
|
||||
assert.equal(options.safety.batch.text.maxChars, 2048);
|
||||
assert.equal(options.includeRawEvent, true);
|
||||
});
|
||||
|
||||
test("normalizes SDK message events for Beaver bridge", () => {
|
||||
const event = bridgeEventFromNormalizedMessage(
|
||||
{
|
||||
messageId: "om_1",
|
||||
chatId: "oc_group",
|
||||
chatType: "group",
|
||||
senderId: "ou_user",
|
||||
content: "hello",
|
||||
rawContentType: "text",
|
||||
resources: [{ type: "image", fileKey: "img_1", fileName: "photo.png" }],
|
||||
mentions: [{ openId: "ou_bot", name: "Beaver", isBot: true }],
|
||||
mentionAll: false,
|
||||
mentionedBot: true,
|
||||
createTime: 1710000000000,
|
||||
raw: { event_id: "evt_1" },
|
||||
},
|
||||
{ connectionId: "conn_1", channelId: "feishu-main", accountId: "feishu:cli_1" },
|
||||
{ maxMessageChars: 100 },
|
||||
);
|
||||
|
||||
assert.equal(event.eventId, "evt_1");
|
||||
assert.equal(event.peerType, "group");
|
||||
assert.equal(event.peerId, "oc_group");
|
||||
assert.equal(event.userId, "ou_user");
|
||||
assert.equal(event.threadId, "oc_group");
|
||||
assert.match(event.content, /^hello/);
|
||||
assert.deepEqual(event.metadata.mentions[0].openId, "ou_bot");
|
||||
assert.deepEqual(event.metadata.resources[0].type, "image");
|
||||
});
|
||||
|
||||
test("uses sender id as peer id for SDK direct messages", () => {
|
||||
const event = bridgeEventFromNormalizedMessage(
|
||||
{
|
||||
messageId: "om_dm",
|
||||
chatId: "oc_dm",
|
||||
chatType: "p2p",
|
||||
senderId: "ou_user",
|
||||
content: "hello dm",
|
||||
rawContentType: "text",
|
||||
resources: [],
|
||||
mentions: [],
|
||||
mentionAll: false,
|
||||
mentionedBot: false,
|
||||
createTime: 1710000000000,
|
||||
raw: { header: { event_id: "evt_dm" } },
|
||||
},
|
||||
{ connectionId: "conn_1", channelId: "feishu-main", accountId: "feishu:cli_1" },
|
||||
);
|
||||
|
||||
assert.equal(event.peerType, "dm");
|
||||
assert.equal(event.peerId, "ou_user");
|
||||
assert.equal(event.threadId, "oc_dm");
|
||||
});
|
||||
|
||||
test("drops empty and oversized SDK message events", () => {
|
||||
assert.equal(
|
||||
bridgeEventFromNormalizedMessage(
|
||||
{
|
||||
messageId: "om_empty",
|
||||
chatId: "oc_dm",
|
||||
chatType: "p2p",
|
||||
senderId: "ou_user",
|
||||
content: " ",
|
||||
rawContentType: "text",
|
||||
resources: [],
|
||||
mentions: [],
|
||||
mentionAll: false,
|
||||
mentionedBot: false,
|
||||
createTime: 1710000000000,
|
||||
},
|
||||
{ connectionId: "conn_1", channelId: "feishu-main", accountId: "feishu:cli_1" },
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
bridgeEventFromNormalizedMessage(
|
||||
{
|
||||
messageId: "om_big",
|
||||
chatId: "oc_dm",
|
||||
chatType: "p2p",
|
||||
senderId: "ou_user",
|
||||
content: "x".repeat(11),
|
||||
rawContentType: "text",
|
||||
resources: [],
|
||||
mentions: [],
|
||||
mentionAll: false,
|
||||
mentionedBot: false,
|
||||
createTime: 1710000000000,
|
||||
},
|
||||
{ connectionId: "conn_1", channelId: "feishu-main", accountId: "feishu:cli_1" },
|
||||
{ maxMessageChars: 10 },
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from external_connector.app import create_app
|
||||
@ -12,6 +13,17 @@ from external_connector.state import SidecarStateStore
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_feishu_node_event_utils() -> None:
|
||||
result = subprocess.run(
|
||||
["node", "--test", "tests/node/feishu_event_utils.test.js"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload: dict[str, object]) -> None:
|
||||
self.payload = payload
|
||||
@ -169,6 +181,36 @@ def test_feishu_bot_provider_connects_with_app_credentials(tmp_path) -> None:
|
||||
assert receiver_starts == ["conn_1"]
|
||||
|
||||
|
||||
def test_feishu_bot_provider_stores_runtime_policy_options(tmp_path) -> None:
|
||||
receiver_starts: list[str] = []
|
||||
provider = _provider(tmp_path, receiver_starts=receiver_starts)
|
||||
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "feishu",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"displayName": "Feishu Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {
|
||||
"appId": "cli_xxx",
|
||||
"appSecret": "secret",
|
||||
"verificationToken": "verify-token",
|
||||
"requireMentionInGroups": True,
|
||||
"allowFrom": ["ou_1"],
|
||||
"groupAllowFrom": ["oc_1"],
|
||||
"maxMessageChars": 1234,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
stored = provider.store.get_session(session["sessionId"])
|
||||
assert stored.metadata["requireMentionInGroups"] is True
|
||||
assert stored.metadata["allowFrom"] == ["ou_1"]
|
||||
assert stored.metadata["groupAllowFrom"] == ["oc_1"]
|
||||
assert stored.metadata["maxMessageChars"] == 1234
|
||||
|
||||
|
||||
def test_feishu_bot_provider_send_uses_tenant_token_and_dedupes(tmp_path) -> None:
|
||||
provider = _provider(tmp_path)
|
||||
session = provider.start_session(
|
||||
@ -205,13 +247,96 @@ def test_feishu_bot_provider_send_uses_tenant_token_and_dedupes(tmp_path) -> Non
|
||||
assert send_posts[0][1]["msg_type"] == "text"
|
||||
|
||||
|
||||
def test_feishu_event_route_returns_challenge(tmp_path) -> None:
|
||||
def test_feishu_bot_provider_send_uses_chat_id_for_group_targets(tmp_path) -> None:
|
||||
provider = _provider(tmp_path)
|
||||
provider.start_session(
|
||||
{
|
||||
"kind": "feishu",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"displayName": "Feishu Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {"appId": "cli_xxx", "appSecret": "secret"},
|
||||
}
|
||||
)
|
||||
|
||||
result = provider.send(
|
||||
{
|
||||
"requestId": "out_group_1",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"kind": "feishu",
|
||||
"target": {"peerId": "oc_group", "peerType": "group", "threadId": "oc_group"},
|
||||
"content": "hello group",
|
||||
"metadata": {},
|
||||
}
|
||||
)
|
||||
|
||||
send_posts = [item for item in provider.http.posts if "/open-apis/im/v1/messages" in item[0]]
|
||||
assert result["ok"] is True
|
||||
assert send_posts[-1][0].endswith("?receive_id_type=chat_id")
|
||||
assert send_posts[-1][1]["receive_id"] == "oc_group"
|
||||
|
||||
|
||||
def test_feishu_bot_provider_send_chunks_oversized_text(tmp_path) -> None:
|
||||
provider = _provider(tmp_path)
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "feishu",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"displayName": "Feishu Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {"appId": "cli_xxx", "appSecret": "secret", "maxMessageChars": 5},
|
||||
}
|
||||
)
|
||||
|
||||
result = provider.send(
|
||||
{
|
||||
"requestId": "out_chunked",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"kind": "feishu",
|
||||
"target": {"peerId": "ou_user", "peerType": "dm", "threadId": None},
|
||||
"content": "helloworld!",
|
||||
"metadata": {},
|
||||
}
|
||||
)
|
||||
|
||||
send_posts = [item for item in provider.http.posts if "/open-apis/im/v1/messages" in item[0]]
|
||||
contents = [json.loads(item[1]["content"])["text"] for item in send_posts]
|
||||
assert session["status"] == "connected"
|
||||
assert result["ok"] is True
|
||||
assert contents == ["hello", "world", "!"]
|
||||
|
||||
|
||||
def test_feishu_event_route_requires_known_verification_token_for_challenge(tmp_path) -> None:
|
||||
provider = _provider(tmp_path)
|
||||
app = create_app(provider=provider, api_token="sidecar-token")
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/feishu/events", json={"challenge": "abc"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_feishu_event_route_returns_challenge_for_matching_token(tmp_path) -> None:
|
||||
provider = _provider(tmp_path)
|
||||
provider.start_session(
|
||||
{
|
||||
"kind": "feishu",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"displayName": "Feishu Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {"appId": "cli_xxx", "appSecret": "secret", "verificationToken": "verify-token"},
|
||||
}
|
||||
)
|
||||
app = create_app(provider=provider, api_token="sidecar-token")
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/feishu/events", json={"challenge": "abc", "token": "verify-token"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"challenge": "abc"}
|
||||
|
||||
@ -259,6 +384,62 @@ def test_feishu_event_route_forwards_message_to_bridge(tmp_path) -> None:
|
||||
assert bridge_posts[0][1]["peerId"] == "ou_user"
|
||||
|
||||
|
||||
def test_feishu_event_route_ignores_bot_sender_and_platform_commands(tmp_path) -> None:
|
||||
bridge_posts: list[tuple[str, dict[str, object], dict[str, str]]] = []
|
||||
provider = _provider(tmp_path, bridge_posts=bridge_posts)
|
||||
provider.start_session(
|
||||
{
|
||||
"kind": "feishu",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"displayName": "Feishu Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {"appId": "cli_xxx", "appSecret": "secret", "verificationToken": "verify-token"},
|
||||
}
|
||||
)
|
||||
app = create_app(provider=provider, api_token="sidecar-token")
|
||||
|
||||
with TestClient(app) as client:
|
||||
bot = client.post(
|
||||
"/feishu/events",
|
||||
json={
|
||||
"header": {"event_id": "evt_bot", "token": "verify-token", "app_id": "cli_xxx"},
|
||||
"event": {
|
||||
"sender": {"sender_type": "bot", "sender_id": {"open_id": "ou_bot"}},
|
||||
"message": {
|
||||
"message_id": "om_bot",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"content": "{\"text\":\"hello\"}",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
command = client.post(
|
||||
"/feishu/events",
|
||||
json={
|
||||
"header": {"event_id": "evt_command", "token": "verify-token", "app_id": "cli_xxx"},
|
||||
"event": {
|
||||
"sender": {"sender_type": "user", "sender_id": {"open_id": "ou_user"}},
|
||||
"message": {
|
||||
"message_id": "om_command",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"content": "{\"text\":\"/feishu start\"}",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert bot.status_code == 200
|
||||
assert command.status_code == 200
|
||||
assert bot.json() == {"ok": True, "ignored": "sender_type:bot"}
|
||||
assert command.json() == {"ok": True, "ignored": "feishu_command"}
|
||||
assert bridge_posts == []
|
||||
|
||||
|
||||
def test_composite_provider_routes_feishu_and_weixin_descriptors(tmp_path) -> None:
|
||||
store = SidecarStateStore(tmp_path / "state.json")
|
||||
provider = CompositeProvider([FakeProvider(store), _provider(tmp_path)])
|
||||
|
||||
@ -70,6 +70,17 @@ def test_sidecar_http_api_requires_bearer_token(tmp_path) -> None:
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_sidecar_http_api_fails_closed_without_configured_token(tmp_path) -> None:
|
||||
app = create_app(provider=FakeProvider(SidecarStateStore(tmp_path / "state.json")), api_token="")
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/connectors")
|
||||
health = client.get("/health")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert health.status_code == 200
|
||||
|
||||
|
||||
def test_sidecar_http_api_session_and_send(tmp_path) -> None:
|
||||
app = create_app(provider=FakeProvider(SidecarStateStore(tmp_path / "state.json")), api_token="sidecar-token")
|
||||
headers = {"Authorization": "Bearer sidecar-token"}
|
||||
|
||||
@ -206,6 +206,38 @@ def test_weixin_ilink_provider_recovers_token_session_persisted_as_scanned(tmp_p
|
||||
assert recovered["accountId"] == "weixin:bot-1@im.bot"
|
||||
|
||||
|
||||
def test_weixin_ilink_provider_starts_existing_connected_receiver_on_startup(tmp_path) -> None:
|
||||
store = SidecarStateStore(tmp_path / "state.json")
|
||||
session = store.create_session(
|
||||
kind="weixin",
|
||||
connection_id="conn_1",
|
||||
channel_id="weixin-main",
|
||||
display_name="Weixin Main",
|
||||
options={},
|
||||
)
|
||||
store.update_session(
|
||||
session.session_id,
|
||||
status="connected",
|
||||
account_id="weixin:bot-1@im.bot",
|
||||
metadata={
|
||||
"token": "bot-token",
|
||||
"baseUrl": "https://api.weixin.example",
|
||||
"userId": "wx-owner",
|
||||
"getUpdatesBuf": "buf",
|
||||
},
|
||||
)
|
||||
|
||||
provider = WeixinIlinkProvider(
|
||||
store=store,
|
||||
http_client=FakeHttpClient(),
|
||||
bridge_base_url="http://beaver:8080",
|
||||
bridge_token="bridge-token",
|
||||
)
|
||||
|
||||
assert "conn_1" in provider._receiver_stops
|
||||
provider.logout("conn_1")
|
||||
|
||||
|
||||
def test_weixin_ilink_provider_send_uses_saved_token_and_dedupes(tmp_path) -> None:
|
||||
http = FakeHttpClient()
|
||||
provider = WeixinIlinkProvider(
|
||||
|
||||
114
scripts/deploy-initial-skills.sh
Normal file → Executable file
114
scripts/deploy-initial-skills.sh
Normal file → Executable file
@ -1,61 +1,101 @@
|
||||
#!/bin/bash
|
||||
# Deploy initial skills to all runtime instances via docker cp
|
||||
# Usage: ./scripts/deploy-initial-skills.sh
|
||||
#!/usr/bin/env bash
|
||||
# Deploy initial skills to app-instance containers without overwriting existing skill directories.
|
||||
# Usage:
|
||||
# ./scripts/deploy-initial-skills.sh
|
||||
# ./scripts/deploy-initial-skills.sh app-instance-terminaltest
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SKILL_SOURCE="/home/ivan/xuan/beaver_project/skills"
|
||||
DOCKER_NAMES=("app-instance-steven" "app-instance-benson" "app-instance-jayc" "app-instance-officebench")
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
SKILL_SOURCE="${SKILL_SOURCE:-${REPO_ROOT}/skills}"
|
||||
SKILL_EXCLUDE="${SKILL_EXCLUDE:-officebench-mcp}"
|
||||
|
||||
SKILLS=(
|
||||
"outlook-mail"
|
||||
"filesystem-operation"
|
||||
"terminal-operation"
|
||||
"web-operation"
|
||||
"utility-tools"
|
||||
"skills-admin"
|
||||
"cron-scheduler"
|
||||
"memory-management"
|
||||
if [[ ! -f "${SKILL_SOURCE}/_index/published.json" ]]; then
|
||||
printf '[deploy-initial-skills] missing published index: %s\n' "${SKILL_SOURCE}/_index/published.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $# -gt 0 ]]; then
|
||||
DOCKER_NAMES=("$@")
|
||||
else
|
||||
mapfile -t DOCKER_NAMES < <(docker ps --format '{{.Names}}' | grep '^app-instance-' | sort)
|
||||
fi
|
||||
|
||||
if [[ "${#DOCKER_NAMES[@]}" -eq 0 ]]; then
|
||||
printf '[deploy-initial-skills] no app-instance containers found\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SKILLS_JSON="$(SKILL_SOURCE="$SKILL_SOURCE" SKILL_EXCLUDE="$SKILL_EXCLUDE" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
source = Path(os.environ["SKILL_SOURCE"])
|
||||
excluded = {item.strip() for item in os.environ.get("SKILL_EXCLUDE", "").split(",") if item.strip()}
|
||||
items = json.loads((source / "_index" / "published.json").read_text(encoding="utf-8")).get("items", [])
|
||||
print(json.dumps([str(item).strip() for item in items if str(item).strip() and str(item).strip() not in excluded]))
|
||||
PY
|
||||
)"
|
||||
|
||||
mapfile -t SKILLS < <(SKILLS_JSON="$SKILLS_JSON" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
for item in json.loads(os.environ["SKILLS_JSON"]):
|
||||
print(item)
|
||||
PY
|
||||
)
|
||||
|
||||
for container in "${DOCKER_NAMES[@]}"; do
|
||||
echo "==> Deploying to $container..."
|
||||
printf '==> Deploying initial skills to %s...\n' "$container"
|
||||
|
||||
docker exec "$container" mkdir -p /root/.beaver/workspace/skills/_index
|
||||
|
||||
for skill in "${SKILLS[@]}"; do
|
||||
if [ -d "$SKILL_SOURCE/$skill" ]; then
|
||||
docker cp "$SKILL_SOURCE/$skill" "$container":/root/.beaver/workspace/skills/
|
||||
echo " + $skill"
|
||||
if [[ ! -d "$SKILL_SOURCE/$skill" ]]; then
|
||||
printf ' ! missing source skill: %s\n' "$skill"
|
||||
continue
|
||||
fi
|
||||
if docker exec "$container" test -e "/root/.beaver/workspace/skills/$skill"; then
|
||||
printf ' = %s\n' "$skill"
|
||||
continue
|
||||
fi
|
||||
docker cp "$SKILL_SOURCE/$skill" "$container":/root/.beaver/workspace/skills/
|
||||
printf ' + %s\n' "$skill"
|
||||
done
|
||||
|
||||
# Merge index: keep existing entries + add new skills, no duplicates
|
||||
docker exec "$container" python3 -c "
|
||||
docker exec -i -e SKILLS_JSON="$SKILLS_JSON" "$container" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
idx = Path('/root/.beaver/workspace/skills/_index/published.json')
|
||||
existing = json.loads(idx.read_text()) if idx.exists() else {'items': []}
|
||||
idx = Path("/root/.beaver/workspace/skills/_index/published.json")
|
||||
try:
|
||||
existing = json.loads(idx.read_text(encoding="utf-8")) if idx.exists() else {"items": []}
|
||||
except json.JSONDecodeError:
|
||||
existing = {"items": []}
|
||||
items = existing.get("items")
|
||||
if not isinstance(items, list):
|
||||
items = []
|
||||
|
||||
new_skills = $(printf '["%s"]' "$(IFS=,; echo "${SKILLS[*]}")" | sed 's/,/", "/g')
|
||||
merged = []
|
||||
for item in [*items, *json.loads(os.environ["SKILLS_JSON"])]:
|
||||
text = str(item).strip()
|
||||
if text and text not in merged:
|
||||
merged.append(text)
|
||||
|
||||
seen = set(existing['items'])
|
||||
for s in new_skills:
|
||||
if s not in seen:
|
||||
existing['items'].append(s)
|
||||
seen.add(s)
|
||||
|
||||
idx.write_text(json.dumps(existing, ensure_ascii=False, indent=2) + '\n')
|
||||
print(f\"Index updated: {len(existing['items'])} skills\")
|
||||
"
|
||||
idx.parent.mkdir(parents=True, exist_ok=True)
|
||||
idx.write_text(json.dumps({"items": merged}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(f" index updated: {len(merged)} skills")
|
||||
PY
|
||||
|
||||
if [[ -f "$SKILL_SOURCE/_index/disabled.json" ]]; then
|
||||
docker cp "$SKILL_SOURCE/_index/disabled.json" "$container":/root/.beaver/workspace/skills/_index/disabled.json
|
||||
fi
|
||||
|
||||
echo " [done]"
|
||||
printf ' [done]\n'
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Done! All skills deployed to all instances."
|
||||
echo "Containers: ${DOCKER_NAMES[*]}"
|
||||
echo "Skills: ${SKILLS[*]}"
|
||||
printf '\nDone. Containers: %s\n' "${DOCKER_NAMES[*]}"
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
{
|
||||
"items": []
|
||||
"items": [
|
||||
"skills-authoring-admin"
|
||||
]
|
||||
}
|
||||
@ -3,11 +3,11 @@
|
||||
"outlook-mail",
|
||||
"filesystem-operation",
|
||||
"terminal-operation",
|
||||
"web-operation",
|
||||
"utility-tools",
|
||||
"skills-admin",
|
||||
"cron-scheduler",
|
||||
"memory-management",
|
||||
"officebench-mcp"
|
||||
"officebench-mcp",
|
||||
"multi-search-engine"
|
||||
]
|
||||
}
|
||||
|
||||
16
skills/multi-search-engine/skill.json
Normal file
16
skills/multi-search-engine/skill.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"created_at": "2026-06-04T09:44:11.388282+00:00",
|
||||
"current_version": "v0001",
|
||||
"description": "Multi search engine integration with 16 engines (7 CN + 9 Global). Supports advanced search operators, time filters, site search, privacy engines, and WolframAlpha knowledge queries. No API keys required.",
|
||||
"display_name": "multi-search-engine",
|
||||
"lineage": [],
|
||||
"name": "multi-search-engine",
|
||||
"owners": [
|
||||
"system",
|
||||
"skillhub"
|
||||
],
|
||||
"source_kind": "initial",
|
||||
"status": "active",
|
||||
"tags": [],
|
||||
"updated_at": "2026-06-04T09:44:11.388282+00:00"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user