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:
2026-06-05 11:46:40 +08:00
parent 236ac19789
commit 2c5205b06e
120 changed files with 8321 additions and 1865 deletions

View File

@ -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=

View File

@ -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 只做并集追加。
## 当前状态
这层已经支持:

View File

@ -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]],

View File

@ -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"}

View File

@ -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(

View File

@ -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,

View File

@ -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`

View File

@ -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"

View File

@ -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:

View File

@ -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()

View File

@ -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:

View File

@ -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 = {}

View File

@ -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(

View File

@ -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(

View File

@ -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")

View File

@ -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}"

View File

@ -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>
)}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>
))}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>
);

View File

@ -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>
);

View File

@ -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')}

View File

@ -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>

View File

@ -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 {

View File

@ -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>
</>
)}
</>
);
};

View File

@ -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'

View File

@ -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}
</>
);
}

View File

@ -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" />

View File

@ -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>
);

View File

@ -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>

View File

@ -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')}

View File

@ -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>

View File

@ -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">

View File

@ -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}`}>

View File

@ -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'

View File

@ -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: {

View File

@ -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>

View File

@ -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}

View File

@ -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}

View File

@ -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>

View File

@ -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}

View File

@ -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> {

View 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);
});
});

View 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;
}

View File

@ -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({

View File

@ -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 步',
});
});
});

View File

@ -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),
};
}

View 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([]);
});
});

View 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
),
};
}

View 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();
});
});

View 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,
}),
};
}

View File

@ -0,0 +1,9 @@
{
"status": "failed",
"failedTests": [
"f5227d990c583bf1cb5e-1c04b5ceddc2ff622275",
"f5227d990c583bf1cb5e-960e0b45f6e36c90c9ae",
"f5227d990c583bf1cb5e-205bf022968074780a04",
"f5227d990c583bf1cb5e-ebfd66136ede563d8670"
]
}

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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;
}
}

View File

@ -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 />}

View File

@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}

View File

@ -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

View File

@ -1,3 +1,3 @@
{
"credentials": []
"credentials": {}
}

View File

@ -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

View File

@ -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`,在用户确认后写入该实例配置并重启实例容器。

View File

@ -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")

View 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

View File

@ -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:-}

View File

@ -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.

View File

@ -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
```

View 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"]`.

View File

@ -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.

View File

@ -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
View 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 通道保存、重启确认、状态错误和响应式;所有实测视口无页面横向越界、无可见小点击目标。

View 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 到登录按钮,或检查辅助功能树。
- 用户影响:屏幕阅读器只能识别为无名称按钮。
- 相关实现:提交按钮默认仅渲染箭头 SVGSVG 为 `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
```

View 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
View 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 被调用,未校验操作系统保存结果。

View 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增强成功反馈。

View 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以保持视觉一致性。

View 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
View 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以保持视觉一致性。

View 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
View 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、候选和草稿状态转换真实后端极端数据仍需在后续页面联测中持续观察。
- 发布高风险草稿的确认分支没有在本轮自动化里实际确认发布,只验证了普通草稿审核路径。

View 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
│ │ ├── < xlOrdinaryTaskCard 列表
│ │ └── >= xl任务表格
│ └── ScheduledTasks
│ ├── 辅助说明
│ ├── 刷新 / 新建定时任务
│ ├── AddJobForm
│ ├── < xlScheduledJobCard 列表
│ └── >= 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 与交互。

View File

@ -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")

View 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,
};

View File

@ -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 }));
}

View File

@ -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"

View File

@ -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)

View 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,
);
});

View File

@ -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)])

View File

@ -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"}

View File

@ -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
View 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[*]}"

View File

@ -1,3 +1,5 @@
{
"items": []
"items": [
"skills-authoring-admin"
]
}

View File

@ -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"
]
}

View 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