feat: add guided exploration and share user-started MCP backend
This commit is contained in:
1 parent
f86db1ced6
commit
ee256ad7ce
144 files changed
+8625
-2075
No files matched your search
@@ -14,6 +14,14 @@
|
||||
|
||||
"""Artemis Admin & Trace Console Package."""
|
||||
|
||||
from apps.admin_console.server import app
|
||||
|
||||
__all__ = ["app"]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
# Workers use shared persistence without importing the HTTP routers or
|
||||
# constructing the web application. Keep the public app import compatible.
|
||||
if name == "app":
|
||||
from apps.admin_console.server import app
|
||||
|
||||
return app
|
||||
raise AttributeError(name)
|
||||
@@ -23,10 +23,7 @@ from artemis.runtime import trace_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from admin_console.database.connection import db_session
|
||||
except ImportError:
|
||||
from apps.admin_console.database.connection import db_session
|
||||
from apps.admin_console.database.connection import db_session
|
||||
|
||||
|
||||
def _canonicalize_status(row: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
@@ -16,10 +16,7 @@ import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from admin_console.database.connection import db_session
|
||||
except ImportError:
|
||||
from apps.admin_console.database.connection import db_session
|
||||
from apps.admin_console.database.connection import db_session
|
||||
|
||||
|
||||
class StepRepository:
|
||||
|
||||
@@ -15,10 +15,7 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from admin_console.database.connection import db_session
|
||||
except ImportError:
|
||||
from apps.admin_console.database.connection import db_session
|
||||
from apps.admin_console.database.connection import db_session
|
||||
|
||||
|
||||
class TraceRepository:
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from apps.admin_console.services.exploration_listing import EXPLORATION_RUNS_CTE
|
||||
from apps.admin_console.services.automation_store import identifier, store
|
||||
from apps.admin_console.services import automation_service as service
|
||||
from apps.admin_console.services import batch_archive as archive_service
|
||||
@@ -260,7 +261,10 @@ def history(
|
||||
batch_id: str = Query(default="", max_length=100),
|
||||
):
|
||||
# SQL filtering/pagination searches the entire persisted history, not a UI cache.
|
||||
query = """WITH history AS (
|
||||
query = (
|
||||
"WITH "
|
||||
+ EXPLORATION_RUNS_CTE
|
||||
+ """, history_base AS (
|
||||
SELECT s.session_id, s.initial_goal, s.start_time,
|
||||
COALESCE(json_extract(j.payload,'$.ended_at'),s.end_time) end_time,
|
||||
COALESCE(j.status,s.status) status, COALESCE(json_extract(j.payload,'$.case.name'),s.initial_goal) name,
|
||||
@@ -269,7 +273,9 @@ def history(
|
||||
COALESCE(json_extract(j.payload,'$.case.category'),'') category,
|
||||
COALESCE(json_extract(j.payload,'$.case.subcategory'),'') subcategory,
|
||||
COALESCE(json_extract(j.payload,'$.case.preconditions'),'') preconditions,
|
||||
COALESCE(json_extract(j.payload,'$.error'),'') error, j.batch_id
|
||||
COALESCE(json_extract(j.payload,'$.error'),'') error, j.batch_id,
|
||||
COALESCE(CASE WHEN json_valid(s.device_info) THEN json_extract(s.device_info,'$.profile') END,
|
||||
json_extract(j.payload,'$.task_config.profile'),json_extract(j.payload,'$.profile')) profile
|
||||
FROM sessions s LEFT JOIN automation_jobs j ON s.session_id=j.session_id
|
||||
UNION ALL
|
||||
SELECT j.session_id,json_extract(j.payload,'$.case.goal'),
|
||||
@@ -280,15 +286,28 @@ def history(
|
||||
COALESCE(json_extract(j.payload,'$.case.category'),''),
|
||||
COALESCE(json_extract(j.payload,'$.case.subcategory'),''),
|
||||
COALESCE(json_extract(j.payload,'$.case.preconditions'),''),
|
||||
COALESCE(json_extract(j.payload,'$.error'),''), j.batch_id
|
||||
COALESCE(json_extract(j.payload,'$.error'),''), j.batch_id,
|
||||
COALESCE(json_extract(j.payload,'$.task_config.profile'),json_extract(j.payload,'$.profile'))
|
||||
FROM automation_jobs j WHERE NOT EXISTS (SELECT 1 FROM sessions s WHERE s.session_id=j.session_id)
|
||||
), history AS (
|
||||
SELECT h.*, e.exploration_id, e.exploration_kind, e.case_version,
|
||||
e.exploration_status, e.exploration_latest_run_id, e.exploration_run_count,
|
||||
e.exploration_case_name, e.exploration_profile
|
||||
FROM history_base h LEFT JOIN exploration_runs e ON e.run_id=h.session_id
|
||||
WHERE COALESCE(e.exploration_kind,'') != 'exploration'
|
||||
OR (e.run_id=e.exploration_latest_run_id AND
|
||||
COALESCE(e.exploration_status,'completed') NOT IN
|
||||
('draft','reserved','queued','running','paused','waiting_user','demonstrating','analyzing_demo','finalizing','interrupted'))
|
||||
) """
|
||||
)
|
||||
where = " WHERE status IN ('completed','success','failed','cancelled','interrupted','skipped')"
|
||||
args = []
|
||||
if batch_id == "__batch__":
|
||||
if batch_id == "__exploration__":
|
||||
where += " AND exploration_kind = 'exploration'"
|
||||
elif batch_id == "__batch__":
|
||||
where += " AND batch_id IS NOT NULL"
|
||||
elif batch_id == "__single__":
|
||||
where += " AND batch_id IS NULL"
|
||||
where += " AND batch_id IS NULL AND COALESCE(exploration_kind,'') != 'exploration'"
|
||||
elif batch_id:
|
||||
try:
|
||||
identifier(batch_id)
|
||||
@@ -297,7 +316,7 @@ def history(
|
||||
where += " AND batch_id = ?"
|
||||
args.append(batch_id)
|
||||
if q:
|
||||
where += " AND (name LIKE ? ESCAPE '\\' OR initial_goal LIKE ? ESCAPE '\\' OR session_id LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\' OR case_id LIKE ? ESCAPE '\\' OR category LIKE ? ESCAPE '\\' OR subcategory LIKE ? ESCAPE '\\' OR preconditions LIKE ? ESCAPE '\\')"
|
||||
where += " AND (COALESCE(exploration_case_name,name) LIKE ? ESCAPE '\\' OR initial_goal LIKE ? ESCAPE '\\' OR session_id LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\' OR case_id LIKE ? ESCAPE '\\' OR category LIKE ? ESCAPE '\\' OR subcategory LIKE ? ESCAPE '\\' OR preconditions LIKE ? ESCAPE '\\')"
|
||||
escaped = "%" + q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"
|
||||
args.extend([escaped] * 8)
|
||||
for condition, value in [
|
||||
@@ -320,6 +339,11 @@ def history(
|
||||
items = [dict(row) for row in rows]
|
||||
jobs = store.jobs_for_sessions([item["session_id"] for item in items])
|
||||
for item in items:
|
||||
item["profile"] = (
|
||||
"explore"
|
||||
if item.get("exploration_kind") == "exploration"
|
||||
else item.get("exploration_profile") or item.get("profile")
|
||||
)
|
||||
item["test_result"] = read_reviewed_result(
|
||||
item["session_id"], item["status"], item.get("error") or "", storage=store
|
||||
)
|
||||
@@ -329,4 +353,6 @@ def history(
|
||||
item["repeat_count"] = job.get("repeat_count", 1)
|
||||
elif item["case_id"]:
|
||||
item["name"] = task_import.case_title(item)
|
||||
if item.get("exploration_kind") == "validation" and item.get("exploration_case_name"):
|
||||
item["name"] = item["exploration_case_name"]
|
||||
return {"items": items, "total": total, "page": page, "size": size}
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Shared exploration operations; no separate Web execution path."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from artemis.config.exploration import ExplorationConfig
|
||||
from artemis.exploration.models import (
|
||||
CreateExploration,
|
||||
ExplorationMessage,
|
||||
ExplorationRequest,
|
||||
SaveCaseRequest,
|
||||
ValidateCaseRequest,
|
||||
DemonstrationRequest,
|
||||
ExtendDemonstration,
|
||||
GenerateCaseRequest,
|
||||
CaseBatchRequest,
|
||||
)
|
||||
from artemis.exploration.store import ExplorationConflict
|
||||
from apps.admin_console.services.exploration_service import exploration_service as service
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/explorations", tags=["exploration"])
|
||||
|
||||
|
||||
@contextmanager
|
||||
def domain_errors():
|
||||
try:
|
||||
yield
|
||||
except KeyError as exc:
|
||||
raise HTTPException(404, str(exc).strip("'")) from exc
|
||||
except ExplorationConflict as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
# Pydantic may echo sensitive input. Invalid nested fields get a plain error.
|
||||
from pydantic import ValidationError
|
||||
|
||||
raise HTTPException(
|
||||
400, "探索参数不合法" if isinstance(exc, ValidationError) else str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
def settings():
|
||||
return service.settings()
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
def save_settings(value: ExplorationConfig):
|
||||
return service.save_settings(value)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_explorations(limit: int = Query(100, ge=1, le=500)):
|
||||
return service.repository.list(limit)
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create(value: CreateExploration, source: str = Query("web", pattern="^(web|mcp)$")):
|
||||
with domain_errors():
|
||||
return service.create(value, source=source)
|
||||
|
||||
|
||||
@router.get("/{session_id}")
|
||||
def detail(session_id: str, after: int = Query(0, ge=0)):
|
||||
with domain_errors():
|
||||
return service.detail(session_id, after=after)
|
||||
|
||||
|
||||
@router.post("/{session_id}/start")
|
||||
async def start(
|
||||
session_id: str, value: ExplorationRequest, source: str = Query("web", pattern="^(web|mcp)$")
|
||||
):
|
||||
with domain_errors():
|
||||
return await service.start(session_id, value.request_id, source=source)
|
||||
|
||||
|
||||
@router.post("/{session_id}/messages")
|
||||
async def message(
|
||||
session_id: str, value: ExplorationMessage, source: str = Query("web", pattern="^(web|mcp)$")
|
||||
):
|
||||
with domain_errors():
|
||||
result = service.message(session_id, value.text, value.request_id, source=source)
|
||||
if not result.get("run_id"):
|
||||
from apps.admin_console.services.automation_service import spawn
|
||||
from artemis.exploration.editorial import discuss
|
||||
|
||||
spawn(discuss(service.repository, session_id, result))
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/{session_id}/pause")
|
||||
def pause(session_id: str):
|
||||
with domain_errors():
|
||||
return service.pause(session_id)
|
||||
|
||||
|
||||
@router.post("/{session_id}/stop")
|
||||
async def stop(session_id: str):
|
||||
with domain_errors():
|
||||
return service.stop(session_id)
|
||||
|
||||
|
||||
@router.post("/{session_id}/continue")
|
||||
async def resume(
|
||||
session_id: str, value: ExplorationRequest, source: str = Query("web", pattern="^(web|mcp)$")
|
||||
):
|
||||
with domain_errors():
|
||||
return await service.resume(session_id, value.request_id, source=source)
|
||||
|
||||
|
||||
@router.get("/{session_id}/case")
|
||||
def case(session_id: str, version: int | None = Query(None, ge=1)):
|
||||
with domain_errors():
|
||||
return service.repository.case(session_id, version)
|
||||
|
||||
|
||||
@router.post("/{session_id}/case")
|
||||
def save_case(session_id: str, value: SaveCaseRequest):
|
||||
with domain_errors():
|
||||
return service.save_case(session_id, value)
|
||||
|
||||
|
||||
@router.post("/{session_id}/validate")
|
||||
async def validate(
|
||||
session_id: str, value: ValidateCaseRequest, source: str = Query("web", pattern="^(web|mcp)$")
|
||||
):
|
||||
with domain_errors():
|
||||
return await service.validate(session_id, value, source=source)
|
||||
|
||||
|
||||
@router.post("/{session_id}/demonstrations")
|
||||
def demonstrate(session_id: str, value: DemonstrationRequest):
|
||||
with domain_errors():
|
||||
return service.demonstrate(session_id, value.request_id, value.source)
|
||||
|
||||
|
||||
@router.post("/{session_id}/demonstrations/{demo_id}/extend")
|
||||
def extend_demonstration(session_id: str, demo_id: str, value: ExtendDemonstration):
|
||||
with domain_errors():
|
||||
return service.demonstration_command(
|
||||
session_id, demo_id, "extend", value.request_id, seconds=value.seconds
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{session_id}/demonstrations/{demo_id}/{command}")
|
||||
def demonstration_command(session_id: str, demo_id: str, command: str, value: ExplorationRequest):
|
||||
if command not in {"finish", "cancel"}:
|
||||
raise HTTPException(404, "示范操作不存在")
|
||||
with domain_errors():
|
||||
return service.demonstration_command(session_id, demo_id, command, value.request_id)
|
||||
|
||||
|
||||
@router.post("/{session_id}/generate")
|
||||
async def generate_case(session_id: str, value: GenerateCaseRequest):
|
||||
with domain_errors():
|
||||
return await service.generate_case(session_id, value)
|
||||
|
||||
|
||||
@router.post("/{session_id}/batch")
|
||||
def case_batch(session_id: str, value: CaseBatchRequest):
|
||||
with domain_errors():
|
||||
return service.create_case_batch(session_id, value)
|
||||
@@ -19,12 +19,8 @@ from fastapi.responses import FileResponse, HTMLResponse
|
||||
|
||||
from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
|
||||
|
||||
try:
|
||||
from admin_console.database.repositories.session_repository import session_repo
|
||||
from admin_console.services.media_service import media_service
|
||||
except ImportError:
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
from apps.admin_console.services.media_service import media_service
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
from apps.admin_console.services.media_service import media_service
|
||||
|
||||
router = APIRouter(tags=["media"])
|
||||
|
||||
@@ -116,11 +112,14 @@ async def get_session_video(session_id: str):
|
||||
|
||||
def _get_session_video_sync(session_id: str):
|
||||
from apps.admin_console.services.evidence_service import plugin_sources
|
||||
from apps.admin_console.services.exploration_evidence import demonstration_sources
|
||||
from apps.admin_console.services.report_export import session_videos
|
||||
|
||||
legacy = _get_legacy_session_video_sync(session_id)
|
||||
sources = []
|
||||
if legacy.get("video_segments") or legacy.get("video_url"):
|
||||
if (
|
||||
legacy.get("video_segments") or legacy.get("video_url")
|
||||
) and "/evidence/demonstrations/" not in str(legacy.get("video_url") or ""):
|
||||
segments = legacy.get("video_segments") or []
|
||||
# Pauses create sibling resume directories. Only collect files already
|
||||
# attached to this task; unrelated recordings never enter its timeline.
|
||||
@@ -149,7 +148,7 @@ def _get_session_video_sync(session_id: str):
|
||||
"url": legacy.get("video_url"),
|
||||
}
|
||||
)
|
||||
sources += plugin_sources(session_id)
|
||||
sources += plugin_sources(session_id) + demonstration_sources(session_id)
|
||||
legacy["sources"] = sources
|
||||
legacy["session_timeline"] = any(source["segments"] for source in sources)
|
||||
row = dict(session_repo.get_session_by_id(session_id) or {})
|
||||
|
||||
@@ -17,12 +17,8 @@ from fastapi import APIRouter, HTTPException
|
||||
|
||||
from artemis.config import WORKSPACE_ROOT
|
||||
|
||||
try:
|
||||
from admin_console.schemas.task_schema import ReplayRequest
|
||||
from admin_console.replay_manager import ReplayManager
|
||||
except ImportError:
|
||||
from apps.admin_console.schemas.task_schema import ReplayRequest
|
||||
from apps.admin_console.replay_manager import ReplayManager
|
||||
from apps.admin_console.schemas.task_schema import ReplayRequest
|
||||
from apps.admin_console.replay_manager import ReplayManager
|
||||
|
||||
replay_manager = ReplayManager(WORKSPACE_ROOT)
|
||||
|
||||
|
||||
@@ -22,22 +22,14 @@ from artemis.config import DB_PATH, TRACES_PATH
|
||||
from artemis.runtime import trace_store
|
||||
from apps.admin_console.services.case_review import read_reviewed_result
|
||||
|
||||
try:
|
||||
from admin_console.core.state import state
|
||||
from admin_console.database.repositories.session_repository import session_repo
|
||||
from admin_console.database.repositories.trace_repository import trace_repo
|
||||
from admin_console.services.media_service import media_service
|
||||
from admin_console.services.model_service import model_service
|
||||
from admin_console.services.automation_store import store
|
||||
from admin_console.services.task_import import execution_title
|
||||
except ImportError:
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
from apps.admin_console.database.repositories.trace_repository import trace_repo
|
||||
from apps.admin_console.services.media_service import media_service
|
||||
from apps.admin_console.services.model_service import model_service
|
||||
from apps.admin_console.services.automation_store import store
|
||||
from apps.admin_console.services.task_import import execution_title
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
from apps.admin_console.database.repositories.trace_repository import trace_repo
|
||||
from apps.admin_console.services.media_service import media_service
|
||||
from apps.admin_console.services.model_service import model_service
|
||||
from apps.admin_console.services.automation_store import store
|
||||
from apps.admin_console.services.task_import import execution_title
|
||||
from apps.admin_console.services.exploration_listing import exploration_metadata
|
||||
|
||||
|
||||
router = APIRouter(tags=["sessions"])
|
||||
@@ -57,6 +49,8 @@ async def list_sessions():
|
||||
|
||||
def _list_sessions_sync():
|
||||
rows = session_repo.get_all_sessions()
|
||||
with store.connection() as connection:
|
||||
exploration_runs = exploration_metadata(connection)
|
||||
jobs_by_id = store.jobs_for_sessions([str(row.get("session_id")) for row in rows])
|
||||
video_rec_map = session_repo.get_video_recordings_map()
|
||||
latest_recordings = session_repo.get_latest_video_recordings_map()
|
||||
@@ -80,6 +74,7 @@ def _list_sessions_sync():
|
||||
|
||||
for row_dict in rows:
|
||||
s_id = str(row_dict.get("session_id"))
|
||||
row_dict.update(exploration_runs.get(s_id, {}))
|
||||
job = jobs_by_id.get(s_id)
|
||||
if job:
|
||||
case = job.get("case") or {}
|
||||
@@ -189,6 +184,19 @@ def _list_sessions_sync():
|
||||
except Exception as e:
|
||||
print(f"[list_sessions] Failed to update orphaned sessions: {e}")
|
||||
|
||||
for row in result:
|
||||
if row.get("exploration_kind") == "validation" and row.get("exploration_case_name"):
|
||||
row["display_name"] = row["exploration_case_name"]
|
||||
model = row.pop("exploration_model", None)
|
||||
if row.get("exploration_kind") == "exploration":
|
||||
row["model_info"] = {
|
||||
"name": "探索",
|
||||
"id": model,
|
||||
"provider": "openai",
|
||||
"architecture": "explore",
|
||||
}
|
||||
elif model:
|
||||
row["model_info"] = {**row["model_info"], "id": model}
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -19,14 +19,9 @@ from fastapi.responses import PlainTextResponse
|
||||
|
||||
from artemis.config import TEST_OUTPUTS_DIR
|
||||
|
||||
try:
|
||||
from admin_console.database.repositories.step_repository import step_repo
|
||||
from admin_console.database.repositories.trace_repository import trace_repo
|
||||
from admin_console.services.media_service import media_service
|
||||
except ImportError:
|
||||
from apps.admin_console.database.repositories.step_repository import step_repo
|
||||
from apps.admin_console.database.repositories.trace_repository import trace_repo
|
||||
from apps.admin_console.services.media_service import media_service
|
||||
from apps.admin_console.database.repositories.step_repository import step_repo
|
||||
from apps.admin_console.database.repositories.trace_repository import trace_repo
|
||||
from apps.admin_console.services.media_service import media_service
|
||||
|
||||
|
||||
router = APIRouter(tags=["steps"])
|
||||
|
||||
@@ -20,10 +20,7 @@ Exposes real-time screen streaming endpoints for the Web UI.
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
try:
|
||||
from admin_console.services.device_stream_service import device_stream_service
|
||||
except ImportError:
|
||||
from apps.admin_console.services.device_stream_service import device_stream_service
|
||||
from apps.admin_console.services.device_stream_service import device_stream_service
|
||||
|
||||
router = APIRouter(tags=["stream"])
|
||||
|
||||
|
||||
@@ -527,7 +527,7 @@ async def get_server_runtime_status():
|
||||
try:
|
||||
from apps.admin_console.core.state import state
|
||||
except ImportError:
|
||||
from admin_console.core.state import state
|
||||
from apps.admin_console.core.state import state
|
||||
|
||||
port = getattr(state, "port", 8000)
|
||||
current_pid = os.getpid()
|
||||
@@ -574,7 +574,7 @@ async def restart_server_endpoint(request: Request):
|
||||
try:
|
||||
from apps.admin_console.core.state import state
|
||||
except ImportError:
|
||||
from admin_console.core.state import state
|
||||
from apps.admin_console.core.state import state
|
||||
|
||||
port = getattr(state, "port", 8000)
|
||||
current_pid = os.getpid()
|
||||
@@ -615,7 +615,7 @@ async def shutdown_server_endpoint(request: Request):
|
||||
try:
|
||||
from apps.admin_console.core.state import state
|
||||
except ImportError:
|
||||
from admin_console.core.state import state
|
||||
from apps.admin_console.core.state import state
|
||||
|
||||
_require_local_lifecycle_request(request)
|
||||
server = getattr(request.app.state, "uvicorn_server", None)
|
||||
|
||||
@@ -16,11 +16,13 @@ import asyncio
|
||||
from contextlib import suppress
|
||||
import json
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from artemis.core.diagnostics import readiness_engine
|
||||
from apps.admin_console.services.automation_store import store
|
||||
from apps.admin_console.services.exploration_listing import exploration_metadata
|
||||
from apps.admin_console.services.task_import import execution_title
|
||||
from artemis.runtime import DeviceExecutionLock, device_pool
|
||||
from artemis.runtime.task_control import (
|
||||
@@ -32,22 +34,13 @@ from artemis.runtime.task_control import (
|
||||
request_resume,
|
||||
)
|
||||
|
||||
try:
|
||||
from admin_console.core.state import state
|
||||
from admin_console.database.repositories.session_repository import session_repo
|
||||
from admin_console.schemas.task_schema import RunRequest
|
||||
from admin_console.services.ipc_service import ipc_service
|
||||
from admin_console.services.model_service import model_service
|
||||
from admin_console.services.task_preset_catalog import task_recommendation_engine
|
||||
from admin_console.services.task_queue_service import task_queue_service
|
||||
except ImportError:
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
from apps.admin_console.schemas.task_schema import RunRequest
|
||||
from apps.admin_console.services.ipc_service import ipc_service
|
||||
from apps.admin_console.services.model_service import model_service
|
||||
from apps.admin_console.services.task_preset_catalog import task_recommendation_engine
|
||||
from apps.admin_console.services.task_queue_service import task_queue_service
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
from apps.admin_console.schemas.task_schema import RunRequest
|
||||
from apps.admin_console.services.ipc_service import ipc_service
|
||||
from apps.admin_console.services.model_service import model_service
|
||||
from apps.admin_console.services.task_preset_catalog import task_recommendation_engine
|
||||
from apps.admin_console.services.task_queue_service import task_queue_service
|
||||
|
||||
|
||||
router = APIRouter(tags=["tasks"])
|
||||
@@ -55,6 +48,7 @@ router = APIRouter(tags=["tasks"])
|
||||
|
||||
class GuidanceRequest(BaseModel):
|
||||
instruction: str = Field(min_length=1, max_length=10000)
|
||||
request_id: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
|
||||
|
||||
def _live_task(session_id: str) -> dict:
|
||||
@@ -77,7 +71,23 @@ def pause_running_task(session_id: str):
|
||||
@router.post("/api/tasks/{session_id}/guide")
|
||||
def guide_running_task(session_id: str, value: GuidanceRequest):
|
||||
_live_task(session_id)
|
||||
guidance = add_instruction(session_id, value.instruction, source="web")
|
||||
from apps.admin_console.services.exploration_service import exploration_service
|
||||
from artemis.exploration.store import ExplorationConflict
|
||||
|
||||
if run := exploration_service.repository.run(session_id):
|
||||
try:
|
||||
guidance = exploration_service.message(
|
||||
run["exploration_id"],
|
||||
value.instruction,
|
||||
value.request_id or str(uuid4()),
|
||||
expected_run_id=session_id,
|
||||
)
|
||||
except ExplorationConflict as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
else:
|
||||
guidance = add_instruction(
|
||||
session_id, value.instruction, source="web", instruction_id=value.request_id
|
||||
)
|
||||
if (
|
||||
read_state(session_id).get("status") == "task_paused"
|
||||
or (control_dir(session_id) / "pause.request").exists()
|
||||
@@ -438,10 +448,15 @@ async def get_status():
|
||||
[str(item["session_id"]) for item in [*queue_data, *active_tasks] if item.get("session_id")]
|
||||
)
|
||||
queue_data = [{**item} for item in queue_data]
|
||||
with store.connection() as connection:
|
||||
exploration_runs = exploration_metadata(connection)
|
||||
for item in [*queue_data, *active_tasks]:
|
||||
item.update(exploration_runs.get(str(item.get("session_id")), {}))
|
||||
if job := batch_jobs.get(str(item.get("session_id"))):
|
||||
item["batch_id"] = job.get("batch_id")
|
||||
item["display_name"] = execution_title(job)
|
||||
if item.get("exploration_kind") == "validation" and item.get("exploration_case_name"):
|
||||
item["display_name"] = item["exploration_case_name"]
|
||||
|
||||
if is_running:
|
||||
is_paused = state.is_paused
|
||||
|
||||
@@ -46,7 +46,7 @@ for _p in (str(_workspace_root), str(_apps_dir), str(_admin_console_dir), str(_c
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
import uvicorn
|
||||
|
||||
@@ -71,28 +71,18 @@ from artemis.config import (
|
||||
)
|
||||
from artemis.resources import get_bundled_showcase_dist
|
||||
|
||||
try:
|
||||
from admin_console.core.security import SameOriginBoundaryMiddleware
|
||||
from admin_console.core.state import ServerState, state
|
||||
from admin_console.database.connection import db_session, get_db
|
||||
from admin_console.database.repositories.session_repository import session_repo
|
||||
from admin_console.database.repositories.step_repository import step_repo
|
||||
from admin_console.database.repositories.trace_repository import trace_repo
|
||||
from admin_console.routers import media, replay, sessions, steps, stream, system, tasks
|
||||
from admin_console.routers.replay import replay_manager
|
||||
from admin_console.services.ipc_service import ipc_service
|
||||
from admin_console.services.media_service import media_service
|
||||
from admin_console.services.model_service import model_service
|
||||
from admin_console.services.task_queue_service import task_queue_service
|
||||
except ImportError:
|
||||
from apps.admin_console.core.security import SameOriginBoundaryMiddleware
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
from apps.admin_console.routers import media, replay, sessions, steps, stream, system, tasks
|
||||
from apps.admin_console.routers.replay import replay_manager
|
||||
from apps.admin_console.services.ipc_service import ipc_service
|
||||
from apps.admin_console.services.model_service import model_service
|
||||
from apps.admin_console.services.task_queue_service import task_queue_service
|
||||
from apps.admin_console.core.security import SameOriginBoundaryMiddleware
|
||||
from apps.admin_console.core.state import ServerState, state
|
||||
from apps.admin_console.database.connection import db_session, get_db
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
from apps.admin_console.database.repositories.step_repository import step_repo
|
||||
from apps.admin_console.database.repositories.trace_repository import trace_repo
|
||||
from apps.admin_console.routers import media, replay, sessions, steps, stream, system, tasks
|
||||
from apps.admin_console.routers.replay import replay_manager
|
||||
from apps.admin_console.services.ipc_service import ipc_service
|
||||
from apps.admin_console.services.media_service import media_service
|
||||
from apps.admin_console.services.model_service import model_service
|
||||
from apps.admin_console.services.task_queue_service import task_queue_service
|
||||
|
||||
# Initialize language server synchronization address
|
||||
init_ls_address()
|
||||
@@ -101,11 +91,33 @@ init_ls_address()
|
||||
@asynccontextmanager
|
||||
async def _lifespan(_app: "FastAPI"):
|
||||
"""Start server resources and release them on shutdown."""
|
||||
await on_startup()
|
||||
from artemis.config.paths import get_app_dir, get_traces_dir
|
||||
from artemis.runtime.daemon_client import configure_local_daemon
|
||||
from artemis.service_registry import ServiceRegistration
|
||||
|
||||
registration = ServiceRegistration(
|
||||
port=state.port,
|
||||
token=LIFECYCLE_TOKEN,
|
||||
app_dir=get_app_dir(),
|
||||
traces_dir=get_traces_dir(),
|
||||
)
|
||||
registration.acquire()
|
||||
app.state.service_registration = registration
|
||||
configure_local_daemon(state.port)
|
||||
try:
|
||||
yield
|
||||
shared_tool_executor.start()
|
||||
async with shared_mcp.session_manager.run():
|
||||
await on_startup()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await on_shutdown()
|
||||
finally:
|
||||
await on_shutdown()
|
||||
try:
|
||||
await shared_tool_executor.stop()
|
||||
finally:
|
||||
configure_local_daemon(None)
|
||||
registration.close()
|
||||
|
||||
|
||||
# Initialize FastAPI application
|
||||
@@ -118,6 +130,26 @@ app.state.lifecycle_token = LIFECYCLE_TOKEN
|
||||
# and unrecognized Host headers (DNS rebinding) instead.
|
||||
app.add_middleware(SameOriginBoundaryMiddleware)
|
||||
|
||||
from mcp_server.shared import LocalMcpBoundary, create_shared_mcp
|
||||
|
||||
shared_mcp, shared_tool_executor = create_shared_mcp()
|
||||
app.mount("/api/mcp", LocalMcpBoundary(shared_mcp.streamable_http_app(), LIFECYCLE_TOKEN))
|
||||
|
||||
|
||||
@app.get("/api/system/shared-service")
|
||||
async def shared_service_identity(request: Request):
|
||||
"""Authenticated identity probe for local connection-only MCP bridges."""
|
||||
import hmac
|
||||
|
||||
from artemis.service_registry import TOKEN_HEADER
|
||||
|
||||
if not hmac.compare_digest(request.headers.get(TOKEN_HEADER, ""), LIFECYCLE_TOKEN):
|
||||
raise HTTPException(status_code=403, detail="Invalid service token")
|
||||
registration = getattr(app.state, "service_registration", None)
|
||||
if registration is None:
|
||||
raise HTTPException(status_code=503, detail="Service is starting")
|
||||
return {key: registration.info[key] for key in ("protocol", "installation", "instance", "pid")}
|
||||
|
||||
|
||||
async def on_startup():
|
||||
"""Startup lifecycle hooks."""
|
||||
@@ -246,6 +278,10 @@ from apps.admin_console.routers.plugins import router as plugins_router
|
||||
|
||||
app.include_router(plugins_router)
|
||||
|
||||
from apps.admin_console.routers.exploration import router as exploration_router
|
||||
|
||||
app.include_router(exploration_router)
|
||||
|
||||
# Mount cloud gateway router for Frappe / Cloud integration if present
|
||||
try:
|
||||
from cloud_service.gateway import cloud_router
|
||||
|
||||
@@ -193,6 +193,9 @@ async def tick_batches():
|
||||
from apps.admin_console.services.report_export import export_batch
|
||||
|
||||
await reconcile_recovered_jobs()
|
||||
from apps.admin_console.services.exploration_recovery import reconcile_explorations
|
||||
|
||||
reconcile_explorations()
|
||||
for batch in store.records("batch"):
|
||||
if batch["status"] not in {"running", "paused"}:
|
||||
continue
|
||||
|
||||
@@ -77,8 +77,10 @@ def plugin_sources(session_id: str) -> list[dict]:
|
||||
|
||||
|
||||
def plugin_files(session_id: str) -> list[tuple[Path, str]]:
|
||||
from apps.admin_console.services.exploration_evidence import demonstration_files
|
||||
|
||||
root = task_root(session_id)
|
||||
files = {}
|
||||
files = dict(demonstration_files(session_id))
|
||||
for source in plugin_sources(session_id):
|
||||
for segment in source["segments"]:
|
||||
path = evidence_file(root, segment["file"])
|
||||
@@ -105,9 +107,13 @@ def plugin_files(session_id: str) -> list[tuple[Path, str]]:
|
||||
|
||||
def export_index(session_id: str, report: dict, android_videos: list) -> dict:
|
||||
from apps.admin_console.services.report_export import session_artifacts, session_screenshots
|
||||
from apps.admin_console.services.exploration_evidence import (
|
||||
exploration_index,
|
||||
demonstration_sources,
|
||||
)
|
||||
|
||||
extras = plugin_files(session_id)
|
||||
recordings = plugin_sources(session_id)
|
||||
recordings = plugin_sources(session_id) + demonstration_sources(session_id)
|
||||
root = task_root(session_id)
|
||||
paths = {path.resolve(): name for path, name in extras}
|
||||
for source in recordings:
|
||||
@@ -138,6 +144,7 @@ def export_index(session_id: str, report: dict, android_videos: list) -> dict:
|
||||
return {
|
||||
"version": 1,
|
||||
"session_id": session_id,
|
||||
"exploration": exploration_index(session_id, paths),
|
||||
"name": report["name"],
|
||||
"status": report["status"],
|
||||
"verdict": report["verdict"],
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Read-only exploration provenance for reports, archives and source-aware video."""
|
||||
|
||||
import json
|
||||
|
||||
from artemis.exploration.store import explorations
|
||||
from artemis.exploration.demonstrations import Demonstrations
|
||||
from apps.admin_console.services.evidence_service import evidence_file, task_root
|
||||
|
||||
|
||||
def exploration_metadata(run_id: str) -> dict | None:
|
||||
run = explorations.run(run_id)
|
||||
if not run:
|
||||
return None
|
||||
return {
|
||||
"id": run["exploration_id"],
|
||||
"kind": run["kind"],
|
||||
"case_version": run.get("case_version"),
|
||||
}
|
||||
|
||||
|
||||
def demonstration_sources(run_id: str) -> list[dict]:
|
||||
root = task_root(run_id)
|
||||
directory = root / "evidence" / "demonstrations"
|
||||
from apps.admin_console.services.media_service import MediaService
|
||||
|
||||
sources = []
|
||||
for manifest in directory.glob("*/demonstration.json"):
|
||||
if (
|
||||
not evidence_file(root, manifest.relative_to(root).as_posix())
|
||||
or manifest.stat().st_size > 4 * 1024 * 1024
|
||||
):
|
||||
continue
|
||||
try:
|
||||
record = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
for track in record["media"]:
|
||||
segments = []
|
||||
for item in track["segments"]:
|
||||
path = evidence_file(root, item["file"])
|
||||
if path and path.suffix.lower() == ".mp4" and item["duration_ms"] > 0:
|
||||
segments.append(
|
||||
{
|
||||
**item,
|
||||
"start": item["offset_ms"] / 1000,
|
||||
"duration": item["duration_ms"] / 1000,
|
||||
"url": MediaService.path_to_video_url(path),
|
||||
}
|
||||
)
|
||||
if segments:
|
||||
sources.append(
|
||||
{
|
||||
**track,
|
||||
"segments": segments,
|
||||
"label": "人工示范 · "
|
||||
+ ("安卓" if track["kind"] == "android" else "电脑"),
|
||||
"status": "ready",
|
||||
"message": "人工操作录像,不等于自动复跑通过",
|
||||
"demonstration_id": record["id"],
|
||||
}
|
||||
)
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
continue
|
||||
return sources
|
||||
|
||||
|
||||
def demonstration_files(run_id: str) -> list[tuple]:
|
||||
root = task_root(run_id)
|
||||
directory = root / "evidence" / "demonstrations"
|
||||
files = []
|
||||
for path in directory.rglob("*"):
|
||||
if path.suffix.lower() not in {".mp4", ".mkv", ".webm"}:
|
||||
continue
|
||||
checked = evidence_file(root, path.relative_to(root).as_posix())
|
||||
if checked:
|
||||
files.append((checked, "任务录屏/人工示范/" + path.relative_to(directory).as_posix()))
|
||||
return files
|
||||
|
||||
|
||||
def exploration_index(run_id: str, archive_paths: dict | None = None) -> dict | None:
|
||||
run = explorations.run(run_id)
|
||||
if not run:
|
||||
return None
|
||||
sid = run["exploration_id"]
|
||||
session = explorations.get(sid)
|
||||
messages = []
|
||||
after = 0
|
||||
while page := explorations.messages(sid, after=after):
|
||||
messages.extend(page)
|
||||
after = page[-1]["sequence"]
|
||||
demos = Demonstrations(explorations).list(sid)
|
||||
root = task_root(run_id)
|
||||
for demo in demos:
|
||||
for track in demo.get("media", []):
|
||||
for segment in track.get("segments", []):
|
||||
relative = segment.pop("file", "")
|
||||
segment["trace_file"] = relative
|
||||
segment["file"] = (
|
||||
(archive_paths or {}).get((root / relative).resolve())
|
||||
if demo["run_id"] == run_id
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"session": session,
|
||||
"messages": messages,
|
||||
"runs": explorations.runs(sid),
|
||||
"cases": [
|
||||
explorations.case(sid, version) for version in range(1, session["case_version"] + 1)
|
||||
],
|
||||
"demonstrations": demos,
|
||||
"note": "此导出保留会话、用例版本和各次运行的关联;只有当前任务的媒体文件在此目录中。",
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Shared exploration metadata for queue cards and history pagination."""
|
||||
|
||||
# Rank execution runs independently from validations: a rerun never replaces the
|
||||
# conversation card. Keep all raw sessions accessible for evidence inspection.
|
||||
EXPLORATION_RUNS_CTE = """
|
||||
exploration_runs AS (
|
||||
SELECT r.id AS run_id,
|
||||
json_extract(r.payload,'$.exploration_id') AS exploration_id,
|
||||
json_extract(r.payload,'$.kind') AS exploration_kind,
|
||||
json_extract(r.payload,'$.case_version') AS case_version,
|
||||
json_extract(r.payload,'$.task_config.model') AS exploration_model,
|
||||
json_extract(r.payload,'$.task_config.profile') AS exploration_profile,
|
||||
json_extract(v.payload,'$.name') AS exploration_case_name,
|
||||
CASE WHEN json_extract(r.payload,'$.kind')='exploration' AND
|
||||
(json_extract(c.payload,'$.active_run_id') IS NULL
|
||||
OR json_extract(c.payload,'$.active_run_id') = r.id)
|
||||
THEN json_extract(c.payload,'$.status')
|
||||
ELSE json_extract(r.payload,'$.status') END AS exploration_status,
|
||||
FIRST_VALUE(r.id) OVER (
|
||||
PARTITION BY json_extract(r.payload,'$.exploration_id'),json_extract(r.payload,'$.kind')
|
||||
ORDER BY json_extract(r.payload,'$.created_at') DESC, r.id DESC
|
||||
) AS exploration_latest_run_id,
|
||||
COUNT(*) OVER (
|
||||
PARTITION BY json_extract(r.payload,'$.exploration_id'),json_extract(r.payload,'$.kind')
|
||||
) AS exploration_run_count
|
||||
FROM automation_records r
|
||||
LEFT JOIN automation_records c ON c.kind='exploration'
|
||||
AND c.id=json_extract(r.payload,'$.exploration_id')
|
||||
LEFT JOIN automation_records v ON v.kind='exploration_case'
|
||||
AND v.id=json_extract(r.payload,'$.exploration_id') || '_' ||
|
||||
printf('%010d',json_extract(r.payload,'$.case_version'))
|
||||
WHERE r.kind='exploration_run'
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def exploration_metadata(connection) -> dict[str, dict]:
|
||||
rows = connection.execute("WITH " + EXPLORATION_RUNS_CTE + " SELECT * FROM exploration_runs")
|
||||
result = {}
|
||||
for row in rows:
|
||||
item = {key: row[key] for key in row.keys() if key != "run_id"}
|
||||
if item.get("exploration_kind") == "validation" and item.get("exploration_case_name"):
|
||||
item["display_name"] = item["exploration_case_name"]
|
||||
result[row["run_id"]] = item
|
||||
return result
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Reconcile saved exploration metadata with the existing queue and worker owners."""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||
from artemis.exploration.store import ExplorationConflict, ExplorationStore, explorations
|
||||
from artemis.runtime.process_probe import pid_is_alive
|
||||
from artemis.runtime.task_control import read_state
|
||||
from artemis.runtime.task_result import read_worker_outcome
|
||||
from artemis.runtime.case_result import read_case_result
|
||||
|
||||
|
||||
def reconcile_explorations(repository: ExplorationStore = explorations) -> None:
|
||||
for kind in ("exploration_generation", "exploration_discussion"):
|
||||
with repository._transaction() as connection:
|
||||
for row in connection.execute(
|
||||
"SELECT id, payload FROM automation_records WHERE kind=? AND json_extract(payload,'$.status')='running'",
|
||||
(kind,),
|
||||
).fetchall():
|
||||
job = json.loads(row[1])
|
||||
if (
|
||||
not pid_is_alive(job.get("owner_pid"))
|
||||
or job.get("lease_until", 0) < time.time()
|
||||
):
|
||||
repository._put(
|
||||
connection,
|
||||
kind,
|
||||
row[0],
|
||||
{
|
||||
**job,
|
||||
"status": "failed",
|
||||
"lease_until": 0,
|
||||
"error": "生成进程已中断;已有结果保留,可重试。",
|
||||
},
|
||||
)
|
||||
with repository.database.connection() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT payload FROM automation_records WHERE kind='exploration' "
|
||||
"AND json_extract(payload,'$.active_run_id') IS NOT NULL"
|
||||
).fetchall()
|
||||
history = SessionRepository(repository.database.path)
|
||||
queued = {
|
||||
str(item.get("session_id"))
|
||||
for item in state.queue_items
|
||||
if isinstance(item, dict) and item.get("status") in {"pending", "running", "queued"}
|
||||
}
|
||||
for row in rows:
|
||||
session = json.loads(row[0])
|
||||
run_id = session["active_run_id"]
|
||||
run = repository.run(run_id)
|
||||
if not run:
|
||||
continue
|
||||
outcome = read_worker_outcome(run_id)
|
||||
if outcome:
|
||||
repository.finish_run(run_id, {**outcome, "status": outcome["task_status"]})
|
||||
continue
|
||||
control = read_state(run_id)
|
||||
if run["kind"] == "validation" and pid_is_alive(control.get("pid")):
|
||||
observed = {"running": "running", "task_paused": "paused", "resuming": "queued"}.get(
|
||||
control.get("status")
|
||||
)
|
||||
if observed and observed != run["status"]:
|
||||
try:
|
||||
repository.set_run_state(run_id, observed, reason=control.get("pause_reason"))
|
||||
except ExplorationConflict:
|
||||
continue # The owner finalized while this tick read its control file.
|
||||
if run_id in queued or run_id in state.active_runs:
|
||||
continue
|
||||
if control.get("pid") and pid_is_alive(control["pid"]):
|
||||
continue
|
||||
execution = history.get_session_by_id(run_id) or {}
|
||||
if execution.get("pid") and history.process_is_alive(execution["pid"]):
|
||||
continue
|
||||
job = repository.database.job(run_id) or {}
|
||||
terminal = execution.get("status") or job.get("terminal_status") or job.get("status")
|
||||
if terminal in {"completed", "failed", "cancelled", "interrupted", "skipped"}:
|
||||
repository.finish_run(
|
||||
run_id,
|
||||
{
|
||||
"status": terminal,
|
||||
"error": execution.get("error") or job.get("error") or "",
|
||||
"test_result": execution.get("test_result")
|
||||
or read_case_result(run_id, terminal),
|
||||
},
|
||||
)
|
||||
elif time.time() - run["created_at"] > 300:
|
||||
repository.finish_run(
|
||||
run_id,
|
||||
{
|
||||
"status": "interrupted",
|
||||
"error": "探索工作进程已结束;原证据已保留,可继续探索并重新观察当前设备。",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Web and MCP use the same exploration admission and conversation service."""
|
||||
|
||||
from artemis.config.agent import load_agent_config
|
||||
from artemis.config.exploration import ExplorationConfig
|
||||
from artemis.exploration.models import CreateExploration, SaveCaseRequest, ValidateCaseRequest
|
||||
from artemis.exploration.demonstrations import Demonstrations
|
||||
from artemis.exploration.store import ExplorationConflict, ExplorationStore, explorations
|
||||
from artemis.runtime.task_control import add_instruction, read_state, request_pause, request_resume
|
||||
from apps.admin_console.services.task_import import TaskOptions
|
||||
|
||||
|
||||
def exploration_case_prompt(case: dict) -> str:
|
||||
prefix = f"前置条件:\n{case['preconditions']}\n\n" if case.get("preconditions") else ""
|
||||
return prefix + case["goal"]
|
||||
|
||||
|
||||
class ExplorationService:
|
||||
def __init__(self, repository: ExplorationStore | None = None):
|
||||
self.repository = repository if repository is not None else explorations
|
||||
|
||||
def settings(self) -> dict:
|
||||
saved = self.repository.database.get("settings", "exploration") or {}
|
||||
return ExplorationConfig.model_validate(
|
||||
{
|
||||
**load_agent_config().exploration.model_dump(),
|
||||
**saved,
|
||||
}
|
||||
).model_dump()
|
||||
|
||||
def save_settings(self, value: ExplorationConfig) -> dict:
|
||||
return self.repository.database.put("settings", "exploration", value.model_dump())
|
||||
|
||||
def create(self, value: CreateExploration, *, source: str = "web") -> dict:
|
||||
options = TaskOptions.model_validate(value.task_config).model_dump(exclude_none=True)
|
||||
return self.repository.create(
|
||||
value.goal,
|
||||
value.config or ExplorationConfig.model_validate(self.settings()),
|
||||
options,
|
||||
value.request_id,
|
||||
source=source,
|
||||
)
|
||||
|
||||
def detail(self, session_id: str, *, after: int = 0) -> dict:
|
||||
session = self.repository.get(session_id)
|
||||
runs = self.repository.runs(session_id)
|
||||
version = self.repository.case(session_id)
|
||||
with self.repository.database.connection() as connection:
|
||||
import json
|
||||
|
||||
generations = [
|
||||
json.loads(row[0])
|
||||
for row in connection.execute(
|
||||
"SELECT payload FROM automation_records WHERE kind='exploration_generation' "
|
||||
"AND json_extract(payload,'$.exploration_id')=? ORDER BY updated_at DESC LIMIT 20",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
]
|
||||
from artemis.runtime.case_result import resolve_case_result
|
||||
|
||||
for run in runs:
|
||||
outcome = run.get("outcome")
|
||||
verdict = resolve_case_result(outcome, run["status"]) if outcome else None
|
||||
run["test_result"] = verdict.model_dump() if verdict else None
|
||||
return {
|
||||
**session,
|
||||
"messages": self.repository.messages(session_id, after=after),
|
||||
"runs": runs,
|
||||
"case": version,
|
||||
"generations": [
|
||||
{k: v for k, v in g.items() if k not in {"owner", "owner_pid", "lease_until"}}
|
||||
for g in generations
|
||||
],
|
||||
"demonstrations": Demonstrations(self.repository).list(session_id),
|
||||
"validation_runs": [
|
||||
r
|
||||
for r in runs
|
||||
if r["kind"] == "validation" and r["case_version"] == session["case_version"]
|
||||
],
|
||||
"control": read_state(session["active_run_id"]) if session["active_run_id"] else {},
|
||||
}
|
||||
|
||||
async def _admit(self, run: dict, *, source: str) -> dict:
|
||||
# RunRequest performs the same readiness and idempotency checks as /api/run.
|
||||
from apps.admin_console.routers.tasks import run_task
|
||||
from apps.admin_console.schemas.task_schema import RunRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
current = self.repository.run(run["id"])
|
||||
if current and current.get("outcome") is not None:
|
||||
return current
|
||||
options = TaskOptions.model_validate(run["task_config"])
|
||||
try:
|
||||
result = await run_task(
|
||||
RunRequest(
|
||||
goal=run["goal"],
|
||||
profile=options.profile or "pro",
|
||||
session_id=run["id"],
|
||||
ingress=source,
|
||||
conversation_id=run["exploration_id"],
|
||||
runtime_overrides=options,
|
||||
)
|
||||
)
|
||||
except HTTPException as exc:
|
||||
if exc.status_code < 500:
|
||||
self.repository.finish_run(
|
||||
run["id"], {"status": "rejected", "error": str(exc.detail)}
|
||||
)
|
||||
raise
|
||||
if result.get("status") in {"error", "rejected"}:
|
||||
self.repository.finish_run(
|
||||
run["id"],
|
||||
{
|
||||
"status": "rejected",
|
||||
"error": result.get("error") or result.get("message") or "任务入队失败",
|
||||
},
|
||||
)
|
||||
else:
|
||||
self.repository.set_run_state(run["id"], "queued", expected_status="reserved")
|
||||
return {"run": self.repository.run(run["id"]), "admission": result}
|
||||
|
||||
async def start(self, session_id: str, request_id: str, *, source: str = "web") -> dict:
|
||||
session = self.repository.get(session_id)
|
||||
config = ExplorationConfig.model_validate(session["config"])
|
||||
options = {
|
||||
**session["task_config"],
|
||||
"profile": "pro",
|
||||
"model": config.model,
|
||||
"reasoning_effort": config.reasoning_effort,
|
||||
"timeout_seconds": config.timeout_seconds,
|
||||
"verification_level": "final",
|
||||
}
|
||||
run = self.repository.reserve_run(
|
||||
session_id,
|
||||
request_id,
|
||||
kind="exploration",
|
||||
goal=session["goal"],
|
||||
task_config=options,
|
||||
)
|
||||
return await self._admit(run, source=source)
|
||||
|
||||
async def validate(
|
||||
self, session_id: str, value: ValidateCaseRequest, *, source: str = "web"
|
||||
) -> dict:
|
||||
case = self.repository.case(session_id, value.version)
|
||||
if case is None:
|
||||
raise KeyError("用例版本不存在")
|
||||
session = self.repository.get(session_id)
|
||||
# Device/plugin binding may be reused; exploration's expensive model is not.
|
||||
options = {
|
||||
k: v
|
||||
for k, v in session["task_config"].items()
|
||||
if k not in {"model", "reasoning_effort", "profile", "timeout_seconds"}
|
||||
}
|
||||
options.update(TaskOptions.model_validate(value.task_config).model_dump(exclude_none=True))
|
||||
options["profile"] = value.profile
|
||||
run = self.repository.reserve_run(
|
||||
session_id,
|
||||
value.request_id,
|
||||
kind="validation",
|
||||
goal=exploration_case_prompt(case),
|
||||
task_config=options,
|
||||
case_version=value.version,
|
||||
)
|
||||
return await self._admit(run, source=source)
|
||||
|
||||
def message(
|
||||
self,
|
||||
session_id: str,
|
||||
text: str,
|
||||
request_id: str,
|
||||
*,
|
||||
source: str = "web",
|
||||
expected_run_id: str | None = None,
|
||||
) -> dict:
|
||||
session = self.repository.get(session_id)
|
||||
if expected_run_id and session["active_run_id"] != expected_run_id:
|
||||
raise ExplorationConflict("所选执行已结束,指导未发送,请刷新任务状态")
|
||||
question = session.get("pending_question")
|
||||
message = self.repository.add_message(
|
||||
session_id,
|
||||
text,
|
||||
request_id,
|
||||
source=source,
|
||||
metadata={"question_id": question, "run_id": session["active_run_id"]},
|
||||
)
|
||||
question = message.get("question_id")
|
||||
run_id = message.get("run_id")
|
||||
if run_id and run_id == session["active_run_id"]:
|
||||
run = self.repository.run(run_id)
|
||||
if run and run.get("outcome") is None:
|
||||
add_instruction(
|
||||
run_id, message["text"], source=source, instruction_id=message["id"]
|
||||
)
|
||||
self.repository.answer_question(session_id, question)
|
||||
if read_state(run_id).get("status") == "task_paused":
|
||||
request_resume(run_id)
|
||||
return message
|
||||
|
||||
def pause(self, session_id: str) -> dict:
|
||||
session = self.repository.get(session_id)
|
||||
if session["status"] == "finalizing":
|
||||
raise ExplorationConflict("设备操作已结束,正在提炼用例,无需暂停。")
|
||||
if not session["active_run_id"]:
|
||||
raise ExplorationConflict("当前没有正在执行的探索")
|
||||
request_pause(session["active_run_id"], reason="user")
|
||||
return {"status": "pausing", "id": session_id}
|
||||
|
||||
def stop(self, session_id: str) -> dict:
|
||||
from apps.admin_console.services.task_queue_service import TaskQueueService
|
||||
|
||||
session = self.repository.get(session_id)
|
||||
if run_id := session["active_run_id"]:
|
||||
requested = TaskQueueService.stop_tasks(session_id=run_id)
|
||||
return {"status": "stopping" if requested else "not_running", "id": session_id}
|
||||
return {"status": session["status"], "id": session_id}
|
||||
|
||||
async def resume(self, session_id: str, request_id: str, *, source: str = "web") -> dict:
|
||||
repo = self.repository
|
||||
with repo._transaction() as connection:
|
||||
session = repo._require(connection, session_id)
|
||||
|
||||
def prepare():
|
||||
if session.get("pending_question"):
|
||||
raise ExplorationConflict("请先回答探索助手的问题,再继续执行。")
|
||||
if run_id := session["active_run_id"]:
|
||||
if read_state(run_id).get("status") != "task_paused":
|
||||
raise ExplorationConflict("请等待探索安全暂停后再继续")
|
||||
request_resume(run_id)
|
||||
return {"status": "resuming", "id": session_id, "run_id": run_id}
|
||||
return {"status": "start", "id": session_id}
|
||||
|
||||
result = repo._operation(connection, f"{session_id}:continue", request_id, {}, prepare)
|
||||
if result["status"] == "resuming":
|
||||
return result
|
||||
return await self.start(session_id, request_id, source=source)
|
||||
|
||||
def save_case(self, session_id: str, value: SaveCaseRequest) -> dict:
|
||||
return self.repository.save_case(
|
||||
session_id, value.case, value.expected_version, value.request_id
|
||||
)
|
||||
|
||||
def demonstrate(self, session_id: str, request_id: str, source: str | None = None) -> dict:
|
||||
session = self.repository.get(session_id)
|
||||
if session["status"] == "finalizing":
|
||||
raise ExplorationConflict("正在提炼用例,请完成后继续探索再示范。")
|
||||
source = source or (
|
||||
"computer"
|
||||
if session["task_config"].get("execution_target") == "computer"
|
||||
else "android"
|
||||
)
|
||||
if source not in {"android", "computer", "both"}:
|
||||
raise ValueError("无效的示范来源")
|
||||
demo = Demonstrations(self.repository).request(session_id, source, request_id)
|
||||
if demo["status"] == "requested":
|
||||
request_pause(demo["run_id"], reason="demonstration")
|
||||
if read_state(demo["run_id"]).get("status") == "task_paused":
|
||||
request_resume(demo["run_id"])
|
||||
return demo
|
||||
|
||||
def demonstration_command(
|
||||
self, session_id: str, demo_id: str, command: str, request_id: str, *, seconds: int = 0
|
||||
) -> dict:
|
||||
return Demonstrations(self.repository).command(
|
||||
session_id, demo_id, command, request_id, seconds=seconds
|
||||
)
|
||||
|
||||
async def generate_case(self, session_id: str, value) -> dict:
|
||||
from artemis.exploration.synthesis import generate_case, _generation
|
||||
from apps.admin_console.services.automation_service import spawn
|
||||
|
||||
generation = _generation(
|
||||
self.repository,
|
||||
session_id,
|
||||
value.request_id,
|
||||
value.source_run_id,
|
||||
value.expected_version,
|
||||
)
|
||||
if generation["status"] == "completed":
|
||||
return {"status": "completed", "id": generation["id"], "case": generation["case"]}
|
||||
|
||||
async def work():
|
||||
try:
|
||||
await generate_case(
|
||||
self.repository,
|
||||
session_id,
|
||||
value.request_id,
|
||||
value.source_run_id,
|
||||
value.expected_version,
|
||||
generation=generation,
|
||||
)
|
||||
except Exception:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception("Exploration synthesis request failed")
|
||||
|
||||
spawn(work())
|
||||
return {"status": "running", "id": generation["id"]}
|
||||
|
||||
def create_case_batch(self, session_id: str, value) -> dict:
|
||||
from apps.admin_console.routers.automation import BatchRequest
|
||||
from apps.admin_console.services.automation_service import create_batch
|
||||
from artemis.exploration.store import _digest
|
||||
|
||||
case = self.repository.case(session_id, value.version)
|
||||
if not case:
|
||||
raise KeyError("用例版本不存在")
|
||||
session = self.repository.get(session_id)
|
||||
options = {
|
||||
k: v
|
||||
for k, v in session["task_config"].items()
|
||||
if k not in {"model", "reasoning_effort", "profile", "timeout_seconds"}
|
||||
}
|
||||
options.update(TaskOptions.model_validate(value.task_config).model_dump(exclude_none=True))
|
||||
options["profile"] = value.profile
|
||||
payload = BatchRequest(
|
||||
name=value.name,
|
||||
profile=value.profile,
|
||||
request_id="explore-" + _digest([session_id, value.request_id]),
|
||||
cases=[
|
||||
{
|
||||
"name": case["name"],
|
||||
"goal": exploration_case_prompt(case),
|
||||
"preconditions": case["preconditions"],
|
||||
"case_id": f"{session_id}-v{value.version}",
|
||||
"task_config": options,
|
||||
"category": "探索用例",
|
||||
"subcategory": f"版本{value.version}",
|
||||
}
|
||||
],
|
||||
).model_dump()
|
||||
return create_batch(payload)
|
||||
|
||||
|
||||
exploration_service = ExplorationService()
|
||||
@@ -20,10 +20,7 @@ from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from admin_console.core.state import state
|
||||
except ImportError:
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.core.state import state
|
||||
|
||||
|
||||
class IPCService:
|
||||
|
||||
@@ -80,6 +80,8 @@ def get_model_settings() -> dict:
|
||||
"tool_fallback_no_progress_attempts": settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS,
|
||||
"reasoning_effort": settings.OPENAI_REASONING_EFFORT,
|
||||
"model_presets": [
|
||||
"gpt-6.1-sol",
|
||||
"gpt-6-astra",
|
||||
"gpt-5.6-luna",
|
||||
"gpt-6-luna",
|
||||
"mimo-v2.6-flash",
|
||||
|
||||
@@ -156,6 +156,8 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
|
||||
|
||||
|
||||
def session_report(session_id: str, db_path=None) -> dict:
|
||||
from apps.admin_console.services.exploration_evidence import exploration_metadata
|
||||
|
||||
identifier(session_id)
|
||||
meta = store.job(session_id) or {}
|
||||
repo = SessionRepository(db_path) if db_path else SessionRepository()
|
||||
@@ -202,6 +204,7 @@ def session_report(session_id: str, db_path=None) -> dict:
|
||||
)
|
||||
return {
|
||||
"id": session_id,
|
||||
"exploration": exploration_metadata(session_id),
|
||||
"name": execution_title(meta) or session.get("initial_goal", session_id)[:120],
|
||||
"iteration": meta.get("iteration", 1),
|
||||
"repeat_count": meta.get("repeat_count", 1),
|
||||
@@ -237,6 +240,21 @@ def time_text(value) -> str:
|
||||
def metadata(report: dict) -> list[tuple]:
|
||||
return [
|
||||
("任务编号", report["id"]),
|
||||
*(
|
||||
(
|
||||
[
|
||||
("探索会话", report["exploration"]["id"]),
|
||||
(
|
||||
"探索关联",
|
||||
"用例 v" + str(report["exploration"]["case_version"]) + " 复跑"
|
||||
if report["exploration"]["kind"] == "validation"
|
||||
else "探索执行(不等于独立复跑通过)",
|
||||
),
|
||||
]
|
||||
)
|
||||
if report.get("exploration")
|
||||
else []
|
||||
),
|
||||
("用例编号", report.get("case_id") or "—"),
|
||||
("检测项目", report.get("category") or "—"),
|
||||
("测试子项", report.get("subcategory") or "—"),
|
||||
@@ -840,7 +858,11 @@ def export_session_media(session_id: str, kind: str) -> Path:
|
||||
artifacts = session_artifacts(report) if kind == "all" else []
|
||||
extras = plugin_files(session_id) if kind in {"video", "all"} else []
|
||||
if kind == "video":
|
||||
extras = [(path, name) for path, name in extras if path.suffix.lower() == ".mp4"]
|
||||
extras = [
|
||||
(path, name)
|
||||
for path, name in extras
|
||||
if path.suffix.lower() in {".mp4", ".mkv", ".webm"}
|
||||
]
|
||||
if kind == "video" and not videos and not extras:
|
||||
raise ValueError("此任务没有已保存的录屏")
|
||||
if kind == "screenshots" and not images:
|
||||
@@ -854,7 +876,7 @@ def export_session_media(session_id: str, kind: str) -> Path:
|
||||
"verdict": report["verdict"],
|
||||
"screenshots": [name for _, name in images],
|
||||
"videos": ["任务录屏/" + name for _, name in videos]
|
||||
+ [name for path, name in extras if path.suffix.lower() == ".mp4"],
|
||||
+ [name for path, name in extras if path.suffix.lower() in {".mp4", ".mkv", ".webm"}],
|
||||
"artifacts": [name for _, name in artifacts],
|
||||
"note": "录屏按时间顺序分段;暂停期间未占用设备,也未录制其他任务。",
|
||||
}
|
||||
@@ -874,7 +896,7 @@ def export_session_media(session_id: str, kind: str) -> Path:
|
||||
source,
|
||||
prefix + name,
|
||||
compress_type=zipfile.ZIP_STORED
|
||||
if source.suffix.lower() == ".mp4"
|
||||
if source.suffix.lower() in {".mp4", ".mkv", ".webm"}
|
||||
else zipfile.ZIP_DEFLATED,
|
||||
)
|
||||
if kind == "all":
|
||||
@@ -955,7 +977,7 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
|
||||
source,
|
||||
f"{prefix}/{name}",
|
||||
compress_type=zipfile.ZIP_STORED
|
||||
if source.suffix.lower() == ".mp4"
|
||||
if source.suffix.lower() in {".mp4", ".mkv", ".webm"}
|
||||
else zipfile.ZIP_DEFLATED,
|
||||
)
|
||||
archive.writestr(
|
||||
|
||||
@@ -25,16 +25,10 @@ import time
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
try:
|
||||
from admin_console.core.state import state
|
||||
from admin_console.database.repositories.session_repository import session_repo
|
||||
from admin_console.services import worker_process_io
|
||||
from admin_console.services.media_service import media_service
|
||||
except ImportError:
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
from apps.admin_console.services import worker_process_io
|
||||
from apps.admin_console.services.media_service import media_service
|
||||
from apps.admin_console.core.state import state
|
||||
from apps.admin_console.database.repositories.session_repository import session_repo
|
||||
from apps.admin_console.services import worker_process_io
|
||||
from apps.admin_console.services.media_service import media_service
|
||||
|
||||
from artemis.config import (
|
||||
PAUSE_FILE,
|
||||
@@ -585,6 +579,12 @@ class TaskQueueService:
|
||||
env["ARTEMIS_SESSION_ID"] = str(sess_id)
|
||||
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
|
||||
env["ARTEMIS_TASK_WORKER"] = "1"
|
||||
from artemis.exploration.store import explorations
|
||||
|
||||
exploration_run = explorations.run(str(sess_id)) if sess_id else None
|
||||
env.pop("ARTEMIS_EXPLORATION_ID", None)
|
||||
if exploration_run and exploration_run["kind"] == "exploration":
|
||||
env["ARTEMIS_EXPLORATION_ID"] = exploration_run["exploration_id"]
|
||||
overrides = task_item.get("runtime_overrides") or {}
|
||||
if overrides.get("recording") is not None:
|
||||
env["ARTEMIS_TASK_RECORDING_CONFIG"] = json.dumps(overrides["recording"])
|
||||
@@ -669,6 +669,11 @@ class TaskQueueService:
|
||||
batch = store.get("batch", job["batch_id"]) if job and job.get("batch_id") else None
|
||||
limit = task_timeout(task_item, job, batch)
|
||||
budget = ExecutionBudget(limit)
|
||||
from artemis.exploration.store import explorations
|
||||
|
||||
exploration_run = explorations.run(sid)
|
||||
is_exploration = bool(exploration_run and exploration_run["kind"] == "exploration")
|
||||
exploration_finalized = False
|
||||
cleanup_limit = (task_item.get("runtime_overrides") or {}).get(
|
||||
"worker_cleanup_timeout_seconds"
|
||||
) or settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS
|
||||
@@ -681,6 +686,9 @@ class TaskQueueService:
|
||||
return waiter.result()
|
||||
outcome = read_worker_outcome(sid)
|
||||
if outcome:
|
||||
if exploration_run and not exploration_finalized:
|
||||
explorations.finish_run(sid, {**outcome, "status": outcome["task_status"]})
|
||||
exploration_finalized = True
|
||||
finalized_at = finalized_at if finalized_at is not None else time.monotonic()
|
||||
if time.monotonic() - finalized_at >= cleanup_limit:
|
||||
logger.warning(
|
||||
@@ -692,6 +700,9 @@ class TaskQueueService:
|
||||
return await waiter
|
||||
continue # Final result is authoritative; only bound resource cleanup now.
|
||||
cls._sync_control_states()
|
||||
if is_exploration:
|
||||
# The worker owns its pausable budget so it survives a UI server restart.
|
||||
continue
|
||||
run = state.active_runs.get(run_key, {})
|
||||
remaining = budget.update(
|
||||
excluded=bool(run.get("parked")) or read_state(sid).get("status") == "queued"
|
||||
@@ -1077,9 +1088,35 @@ class TaskQueueService:
|
||||
f"[QueueWorker] Could not persist failure status for [{sess_id}]"
|
||||
)
|
||||
finally:
|
||||
await cls._finish_output_forwarder(output_task)
|
||||
# 5. Clean up the finished task and release this run's scheduling slot
|
||||
cls._release_run_slot(sess_id, run_key, proc)
|
||||
try:
|
||||
await cls._finish_output_forwarder(output_task)
|
||||
if sess_id:
|
||||
from artemis.exploration.store import explorations
|
||||
from artemis.runtime.task_result import read_worker_outcome
|
||||
|
||||
exploration_run = explorations.run(str(sess_id))
|
||||
if exploration_run and exploration_run.get("outcome") is None:
|
||||
from artemis.runtime.case_result import read_case_result
|
||||
|
||||
outcome = read_worker_outcome(str(sess_id)) or {}
|
||||
session = session_repo.get_session_by_id(str(sess_id)) or {}
|
||||
status = (
|
||||
outcome.get("task_status") or session.get("status") or "interrupted"
|
||||
)
|
||||
explorations.finish_run(
|
||||
str(sess_id),
|
||||
{
|
||||
**outcome,
|
||||
"status": status,
|
||||
"test_result": outcome.get("test_result")
|
||||
or read_case_result(str(sess_id), status),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Cannot finalize exploration metadata for %s", sess_id)
|
||||
finally:
|
||||
# Metadata/storage failure must never leak a scheduling slot.
|
||||
cls._release_run_slot(sess_id, run_key, proc)
|
||||
|
||||
@classmethod
|
||||
def _find_duplicate_submission(
|
||||
|
||||
@@ -506,6 +506,7 @@ limitations under the License.
|
||||
|
||||
<!-- Event Logs Section -->
|
||||
<main class="stream-logs-content">
|
||||
<ng-content select="[task-summary]"></ng-content>
|
||||
@if (agentService.isSessionContentLoading()) {
|
||||
<div class="session-loading-state" aria-live="polite">
|
||||
<span class="material-symbols-outlined spin-animation">progress_activity</span>
|
||||
@@ -513,7 +514,7 @@ limitations under the License.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!agentService.isSessionContentLoading() && consolidatedBlocks().length === 0 && !showPlanningLoader() && startupWorkItems().length === 0) {
|
||||
@if (!agentService.isSessionContentLoading() && phases().length === 0 && !showPlanningLoader() && startupWorkItems().length === 0) {
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div>
|
||||
<h3>尚未选择执行记录</h3>
|
||||
@@ -552,6 +553,19 @@ limitations under the License.
|
||||
</div>
|
||||
}
|
||||
@for (phase of phases(); track trackPhase($index, phase); let first = $first) {
|
||||
@if(phase.demonstration; as demo) {
|
||||
<article class="exploration-message demonstration"><strong>人工示范 · {{demo.source === 'android' ? '安卓' : demo.source === 'computer' ? '电脑' : '安卓 + 电脑'}} · {{demoLabel(demo.status)}}</strong>
|
||||
@if(demo.analysis){<div class="markdown-content" [innerHTML]="renderMarkdown(demo.analysis.summary, phase.id)"></div>}
|
||||
@if(demo.error){<p class="error">{{demo.error}}</p>}
|
||||
@for(track of demo.media; track $index){@for(segment of track.segments; track segment.file){<details><summary>{{track.kind === 'android' ? '安卓' : '电脑'}}示范录像 · 片段 {{$index + 1}}</summary><video controls preload="none" [src]="demoMediaUrl(demo.run_id,segment.file)"></video><a [href]="demoMediaUrl(demo.run_id,segment.file)" download>下载录像</a></details>}}
|
||||
</article>
|
||||
} @else if(phase.conversation; as message) {
|
||||
<article class="exploration-message" [class.user]="message.role === 'user'" [class.assistant]="message.role === 'assistant'" [class.question]="pendingQuestion() === message.id" [class.system]="message.role === 'system'">
|
||||
<small><span class="material-symbols-outlined">{{message.role === 'user' ? 'person' : message.role === 'assistant' ? 'auto_awesome' : 'info'}}</span>{{message.role === 'user' ? '你的指导' : message.role === 'assistant' ? '探索助手' : '探索记录'}} · {{message.created_at * 1000 | date:'HH:mm:ss'}} @if(pendingQuestion() === message.id){<b>等待你回答</b>}</small>
|
||||
<div class="markdown-content" [innerHTML]="renderMarkdown(message.text, phase.id)"></div>
|
||||
@if(message.recommendation){<div class="recommendation">建议:{{message.recommendation}} @if(pendingQuestion() === message.id){<button (click)="useRecommendation.emit(message.recommendation)">采用建议</button>}</div>}
|
||||
</article>
|
||||
} @else {
|
||||
<div class="phase-container">
|
||||
@if (hasVisibleBlocks(phase) && phase.blocks[0]?.data?.phase !== 'outcome') {
|
||||
<div
|
||||
@@ -1323,8 +1337,9 @@ limitations under the License.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (awaitingGuidance().length) {
|
||||
@if (!conversationMessages().length && awaitingGuidance().length) {
|
||||
<div class="guidance-queue" aria-label="待处理用户指导">
|
||||
@for (item of awaitingGuidance(); track item.id) {
|
||||
<div class="guidance-queue-item">
|
||||
@@ -1427,6 +1442,7 @@ limitations under the License.
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<ng-content select="[task-result]"></ng-content>
|
||||
</main>
|
||||
|
||||
<!-- Fullscreen Image Zoom Lightbox Modal -->
|
||||
|
||||
@@ -4066,3 +4066,13 @@ $stream-row-height: 25px;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.stream-logs-content {padding-bottom:24px}
|
||||
.exploration-message {padding:12px 16px;border:1px solid #e2e8f0;border-radius:12px;margin:14px 0;background:#fff;color:#334155;font-size:13px;line-height:1.7}
|
||||
.exploration-message.user {background:#eff6ff;border-color:#dbeafe;margin-left:28px}.exploration-message.system {color:#64748b;background:#f8fafc;font-size:12px}
|
||||
.exploration-message small {color:#64748b;font-size:11px}.exploration-message p {white-space:pre-wrap;overflow-wrap:anywhere;margin:6px 0}.exploration-message button {font:inherit;padding:4px 10px;border:1px solid #cbd5e1;border-radius:6px;background:#fff;color:#334155;cursor:pointer}
|
||||
|
||||
.exploration-message video {width:100%;max-height:300px;background:#0f172a}.exploration-message summary {cursor:pointer}.exploration-message .error {color:#b91c1c}
|
||||
.exploration-message.assistant {border-left:3px solid #818cf8;background:#fafaff}
|
||||
.exploration-message.question {border:1px solid #f4c97a;border-left:3px solid #f59e0b;background:#fffbeb}
|
||||
.exploration-message small {display:flex;align-items:center;gap:6px;font-size:11px;margin-bottom:8px}.exploration-message small .material-symbols-outlined {font-size:16px;color:#6366f1}.exploration-message small b {margin-left:auto;color:#92400e;background:#fef3c7;border-radius:12px;padding:2px 8px}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ExplorationMessage, Demonstration } from '../../services/exploration.service';
|
||||
import { mergeConversation } from '../../utils/exploration-timeline.util';
|
||||
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||
import { TaskHistoryComponent } from '../task-history/task-history.component';
|
||||
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
|
||||
@@ -18,7 +20,7 @@ import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Component, ChangeDetectionStrategy, NgZone, signal, computed, effect, inject, untracked, DestroyRef, AfterViewInit } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, NgZone, signal, computed, effect, inject, untracked, DestroyRef, AfterViewInit, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
@@ -248,6 +250,13 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AgentStreamComponent implements AfterViewInit {
|
||||
conversationMessages = input<ExplorationMessage[]>([]);
|
||||
demonstrations = input<Demonstration[]>([]);
|
||||
pendingQuestion = input('');
|
||||
demoMediaUrl(runId: string, path: string) { return '/videos/' + [runId, ...path.split('/')].map(encodeURIComponent).join('/'); }
|
||||
demoLabel(status: string) { return ({requested:'准备录制',recording:'录制中',analyzing:'分析中',completed:'已分析',failed:'分析失败',cancelled:'已取消',timed_out:'到时已保存'} as Record<string,string>)[status] || status; }
|
||||
useRecommendation = output<string>();
|
||||
|
||||
public agentService = inject(AgentService);
|
||||
private http = inject(HttpClient);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
@@ -393,7 +402,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
public activeQueue = computed(() => {
|
||||
const list = this.agentService.sessions().filter((s) => {
|
||||
const status = this.getTaskStatus(s);
|
||||
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s);
|
||||
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s) && this.agentService.isQueueRepresentative(s);
|
||||
});
|
||||
return list.sort((a, b) => {
|
||||
const statusA = this.getTaskStatus(a);
|
||||
@@ -501,9 +510,9 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录';
|
||||
}
|
||||
|
||||
public phases = computed<PhaseBlock[]>(() => {
|
||||
public phases = computed(() => {
|
||||
const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId());
|
||||
return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0);
|
||||
return mergeConversation(groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0), this.conversationMessages(), this.demonstrations());
|
||||
});
|
||||
|
||||
public startupWorkItems = computed(() => {
|
||||
@@ -568,6 +577,12 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
constructor() {
|
||||
const conversationRevision = computed(() => JSON.stringify([
|
||||
this.conversationMessages().map(message => message.id),
|
||||
this.demonstrations().map(demo => [demo.id,demo.status])
|
||||
]));
|
||||
effect(() => { conversationRevision(); untracked(() => this.scheduleAutoScroll(false)); });
|
||||
|
||||
effect((onCleanup) => {
|
||||
const id = this.agentService.currentSessionId();
|
||||
const status = this.selectedTerminalStatus();
|
||||
@@ -924,6 +939,9 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
public getTaskStatus(session: Session): string {
|
||||
const exploration = this.agentService.explorationQueueStatus(session);
|
||||
if (exploration) return exploration as 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled';
|
||||
|
||||
if (session.status) {
|
||||
const s = session.status.toLowerCase();
|
||||
if (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(s)) {
|
||||
@@ -2233,6 +2251,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
public getModelIcon(name: string | undefined | null): string {
|
||||
if (!name) return 'bolt';
|
||||
const lower = name.toLowerCase();
|
||||
if (lower === '探索') return 'explore';
|
||||
if (lower.includes('pro')) return 'diamond';
|
||||
if (lower.includes('flash')) return 'bolt';
|
||||
return 'smart_toy';
|
||||
@@ -2248,6 +2267,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
|
||||
public getArchitectureTooltip(model?: ModelInfo | null): string {
|
||||
if (!model) return "执行模式:ARTEMIS Flash(快速响应)";
|
||||
if (model.architecture === 'explore') return `探索模式(可交流与人工示范) · LLM: ${model.id}`;
|
||||
const name = this.getModelDisplayName(model.name);
|
||||
const isPro = name.toLowerCase().includes('pro');
|
||||
const archDesc = isPro
|
||||
|
||||
@@ -82,9 +82,11 @@ limitations under the License.
|
||||
@if (getTaskStatus(session) === 'running') {
|
||||
<span class="status-dot"></span>
|
||||
}
|
||||
{{ getTaskStatus(session) | uiLabel }}
|
||||
{{ agentService.explorationStatusLabel(session) || (getTaskStatus(session) | uiLabel) }}
|
||||
</span>
|
||||
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
|
||||
@if (session.exploration_kind !== 'exploration') { <app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" /> }
|
||||
<app-task-mode-badge [session]="session" />
|
||||
@if (session.exploration_kind === 'validation') { <span class="task-origin-badge">用例复跑 · v{{session.case_version}}</span> }
|
||||
</div>
|
||||
<div class="task-card-header-actions">
|
||||
@if (getDeviceSerial(session)) {
|
||||
|
||||
@@ -335,7 +335,8 @@ $text-muted: #a1a1aa;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
|
||||
.task-status-group { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
||||
.task-status-group { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; }
|
||||
.task-origin-badge { display: inline-flex; align-items: center; box-sizing: border-box; height: 20px; padding: 0 6px; border: 1px solid #e2e8f0; border-radius: 5px; background: #f8fafc; color: #64748b; font-size: 11px; line-height: 14px; white-space: nowrap; }
|
||||
|
||||
.task-badge {
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -24,6 +24,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { Session } from '../../core/models/session.model';
|
||||
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
||||
import { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-badge.component';
|
||||
import { TaskBatchFilterComponent } from '../task-batch-filter/task-batch-filter.component';
|
||||
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
|
||||
import { parseNote, parseNoteLines } from '../../utils/markdown-parser.util';
|
||||
@@ -33,7 +34,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
|
||||
@Component({
|
||||
selector: 'app-chat-interface',
|
||||
standalone: true,
|
||||
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent, TestResultBadgeComponent, TaskBatchFilterComponent],
|
||||
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent, TestResultBadgeComponent, TaskModeBadgeComponent, TaskBatchFilterComponent],
|
||||
templateUrl: './chat-interface.component.html',
|
||||
styleUrl: './chat-interface.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -56,7 +57,7 @@ export class ChatInterfaceComponent {
|
||||
public activeQueue = computed(() => {
|
||||
const list = this.agentService.sessions().filter((s) => {
|
||||
const status = this.getTaskStatus(s);
|
||||
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s);
|
||||
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s) && this.agentService.isQueueRepresentative(s);
|
||||
});
|
||||
return list.sort((a, b) => {
|
||||
const statusA = this.getTaskStatus(a);
|
||||
@@ -182,6 +183,9 @@ export class ChatInterfaceComponent {
|
||||
* Determine the current task execution status
|
||||
*/
|
||||
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
|
||||
const exploration = this.agentService.explorationQueueStatus(session);
|
||||
if (exploration) return exploration as 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled';
|
||||
|
||||
if (session.status) {
|
||||
const s = session.status.toLowerCase();
|
||||
if (s === 'completed' || s === 'success' || s === 'failed' || s === 'cancelled') {
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
@if(session(); as s) {
|
||||
<app-agent-stream [conversationMessages]="messages()" [demonstrations]="s.demonstrations" [pendingQuestion]="s.pending_question || ''" (useRecommendation)="text = $event; send()">
|
||||
<section task-summary class="session-summary">
|
||||
<div class="row"><strong><span class="material-symbols-outlined">explore</span> {{activeValidation() ? '用例复跑中' : label(s.status)}}</strong><small>{{s.config.model}} / {{s.config.reasoning_effort}} · 续接 {{s.continuation_count}} 次</small></div>
|
||||
<p>{{s.goal}}</p>
|
||||
@if(s.pause_reason === 'execution_budget'){<p class="feedback">本段执行到时,进度已保存。可继续探索,不计为测试失败。</p>}
|
||||
@if(s.pause_reason === 'device_observation'){<p class="feedback" role="status">设备画面暂时不可用,探索已暂停。指导已保留,请确认设备恢复响应后点击“继续探索”。</p>}
|
||||
<details class="execution-history"><summary>执行记录({{s.runs.length}}) · 当前展示所选轮次的操作步骤</summary>@for(run of s.runs; track run.id){<div class="run"><button class="run-link" (click)="agent.selectSession(run.id,true)">{{run.kind === 'validation' ? '用例 v' + run.case_version + ' 复跑' : '探索执行'}} · {{label(run.status)}}{{agent.currentSessionId() === run.id ? ' · 当前查看' : ''}}</button><app-test-result-badge [result]="run.test_result" [executionStatus]="run.status" [compact]="true" /></div>}</details>
|
||||
</section>
|
||||
<div task-result>
|
||||
@if(showCase()) {
|
||||
<section class="case-editor" aria-label="可复用用例"><div class="row"><strong class="case-title"><span class="material-symbols-outlined">task_alt</span>{{s.case ? '探索成果 · 可复用用例' : '本轮探索已结束'}}</strong><button (click)="generate()" [disabled]="busy() || !!s.active_run_id || generating() || dirty">{{generating() ? '正在提炼…' : s.case ? '重新提炼' : '提炼用例'}}</button></div>
|
||||
@if(!s.case){<p class="muted">探索验收通过后自动生成;也可以先编辑并保存草稿。</p><button (click)="editorOpen.set(true)">编辑草稿</button>}
|
||||
@if(s.generations[0]?.status === 'failed'){<p class="error">上次提炼未完成,执行结果保留。可重试提炼。</p>}
|
||||
@if(s.case){<div class="case-compact"><h3>{{s.case.name}} <small>v{{s.case.version}}</small></h3><p class="muted">已整理为一个可复用用例。你可以查看修改,或交给 Flash / Pro 独立复跑。</p><div class="actions"><button class="primary" (click)="copyCase()"><span class="material-symbols-outlined">content_copy</span>复制任务提示词</button><button (click)="editorOpen.set(!editorOpen())">{{editorOpen() ? '收起编辑' : '查看 / 编辑'}}</button><button (click)="rerunOpen.set(!rerunOpen())">复跑 / 批次</button></div></div>}
|
||||
@if(editorOpen()) {
|
||||
@if(s.case){<label>查看版本<select [ngModel]="editVersion" (ngModelChange)="selectVersion($event)" [disabled]="dirty">@for(version of versions(); track version){<option [value]="version">v{{version}}</option>}</select></label>}
|
||||
@if(dirty && editVersion !== s.case_version){<p class="error">已有新版本;当前编辑仍保留,请先复制修改内容再加载最新版本。</p>}
|
||||
<label>用例名称<input [(ngModel)]="editName" (ngModelChange)="dirty = true" maxlength="200" /></label><label>前置条件<textarea [(ngModel)]="editPreconditions" (ngModelChange)="dirty = true" rows="2" maxlength="10000"></textarea></label><label>任务提示词<textarea [(ngModel)]="editGoal" (ngModelChange)="dirty = true" rows="7" maxlength="10000"></textarea></label>
|
||||
<div class="actions"><button (click)="saveCase()" [disabled]="busy() || !dirty || !editName.trim() || !editGoal.trim()">保存新版本</button><button (click)="copyCase()" [disabled]="!editGoal.trim()">复制提示词</button></div>
|
||||
}
|
||||
@if(rerunOpen()) {<div class="rerun-options"><datalist id="exploration-models">@for(model of models;track model){<option [value]="model"></option>}</datalist><strong>复跑与批次</strong><p class="muted">固定使用所选已保存版本。复跑通过与探索通过分别记录。</p><label>执行模式<select [(ngModel)]="rerunProfile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label><label>复跑模型<input [(ngModel)]="rerunModel" list="exploration-models" placeholder="留空使用 Flash / Pro 默认模型" /></label><label>思考强度<select [(ngModel)]="rerunReasoning">@for(effort of efforts; track effort){<option [value]="effort">{{effort}}</option>}</select></label><div class="actions"><button (click)="rerun()" [disabled]="busy() || dirty || !editVersion || !!s.active_run_id">复跑此版本</button><button (click)="rerun('batch')" [disabled]="busy() || dirty || !editVersion">加入新批次草稿</button></div></div>}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
</app-agent-stream>
|
||||
<section class="composer" [class.needs-answer]="!!s.pending_question" aria-label="指导当前探索">
|
||||
@if(error()){<p role="alert" class="feedback error">{{error()}} <button (click)="refresh()">重试读取</button></p>}
|
||||
@if(notice()){<p role="status" class="feedback">{{notice()}}</p>}
|
||||
@if(s.pending_question) {
|
||||
<div class="question-banner" role="status" aria-live="polite"><span class="material-symbols-outlined">chat_bubble</span><div><strong>探索已暂停,等待你回答</strong><p>{{pendingQuestion()?.text || '探索助手有一个问题需要你确认,请查看上方对话。'}}</p>@if(pendingQuestion()?.recommendation; as suggestion){<button (click)="text = suggestion; send()" [disabled]="busy()">采用建议并继续</button>}</div></div>
|
||||
} @else if(finalizing()) {
|
||||
<div class="feedback" role="status">设备操作已结束,正在根据已验证的步骤提炼用例。完成后会在上方展示。</div>
|
||||
}
|
||||
@if(activeDemo(); as demo) {
|
||||
<section class="demo" [class.warning]="demo.status === 'recording' && secondsLeft(demo) <= demo.warning_seconds" aria-live="polite"><strong><span class="record-dot" [class.recording]="demo.status === 'recording'"></span>{{label(demo.status)}}</strong>
|
||||
@if(demo.status === 'recording'){<p>现在可以操作设备。剩余 {{secondsLeft(demo)}} 秒;AI 已暂停操作。</p><div class="actions"><button class="primary" (click)="demoAction('finish')" [disabled]="busy()">操作完成,开始分析</button><button (click)="demoAction('extend')" [disabled]="busy()">延长示范</button></div>}
|
||||
@else {<p>{{demo.status === 'requested' ? '正在等待当前动作安全结束,请等“录制中”出现后再操作。' : '正在保存并分析本次示范录像。'}}</p>}
|
||||
@if(['requested','recording'].includes(demo.status)){<button (click)="demoAction('cancel')" [disabled]="busy()">取消示范</button>}
|
||||
</section>
|
||||
} @else if(demoOptionsOpen() && s.active_run_id && !activeValidation() && ['running','waiting_user'].includes(s.status)) {
|
||||
<div class="manual-control"><label>示范来源<select [(ngModel)]="source"><option value="android">安卓</option><option value="computer">电脑</option><option value="both">安卓 + 电脑</option></select></label><button (click)="action('demonstrations', {source})" [disabled]="busy()"><span class="material-symbols-outlined">touch_app</span>开始示范</button></div>
|
||||
}
|
||||
|
||||
@if(activeValidation()){<p class="feedback">用例正在独立复跑,请进入复跑任务查看或指导。<button (click)="agent.selectSession(s.active_run_id!,true)">查看当前复跑</button></p>}
|
||||
<div class="composer-heading"><label for="exploration-message">{{s.pending_question ? '回答探索助手' : '与探索助手交流'}}</label><span class="composer-status" [class.waiting]="!!s.pending_question">{{label(s.status)}}</span></div>
|
||||
<textarea id="exploration-message" [(ngModel)]="text" (keydown)="onKeyDown($event)" rows="2" maxlength="10000" [placeholder]="s.pending_question ? '在这里回答,发送后继续探索…' : '告诉我下一步怎么做,或补充你的想法…'" [disabled]="busy() || activeValidation() || finalizing()"></textarea>
|
||||
<div class="actions composer-actions">
|
||||
@if(!activeDemo() && s.active_run_id && !activeValidation() && ['running','waiting_user'].includes(s.status)){<button class="tool-button" (click)="demoOptionsOpen.set(!demoOptionsOpen())" [attr.aria-expanded]="demoOptionsOpen()"><span class="material-symbols-outlined">touch_app</span>我来操作</button>}
|
||||
@if(!activeValidation() && !s.pending_question && (!s.active_run_id || s.status === 'paused')){<button (click)="action(s.status === 'draft' ? 'start' : 'continue')" [disabled]="busy()">{{s.status === 'draft' ? '开始探索' : '继续探索'}}</button>}
|
||||
@if(s.active_run_id && !activeValidation() && !finalizing()){<button class="tool-button" (click)="action('pause')" [disabled]="busy() || ['paused','waiting_user','demonstrating'].includes(s.status)">暂停</button><button class="tool-button" (click)="action('stop')" [disabled]="busy()">停止</button>}
|
||||
<small class="send-hint">Enter 发送 · Shift+Enter 换行</small>
|
||||
<button class="primary send-button" (click)="send()" [disabled]="busy() || activeValidation() || finalizing() || !text.trim()" [attr.aria-label]="s.pending_question ? '回答并继续' : '发送'"><span>{{s.pending_question ? '回答并继续' : '发送'}}</span><span class="material-symbols-outlined">arrow_upward</span></button>
|
||||
</div>
|
||||
@if(!s.active_run_id){<p class="composer-footnote">可在此讨论和修订用例;继续操作设备请点击“继续探索”。</p>}
|
||||
</section>
|
||||
} @else {<div class="empty" role="status">{{loading() ? '正在读取探索记录…' : '探索记录暂时无法读取'}} @if(error()){<p class="error">{{error()}}</p><button (click)="refresh()">重试</button>}</div>}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
:host { display:flex; flex-direction:column; min-height:0; height:100%; overflow:hidden; color:#334155; font-size:13px; }
|
||||
.exploration-panel { padding:18px; display:flex; flex-direction:column; gap:16px; }
|
||||
header,.row,.actions,.manual-control { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
header,.row { justify-content:space-between; }
|
||||
header strong { font-size:15px; color:#0f172a; }
|
||||
p { margin:8px 0; line-height:1.65; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
header p,.muted,small { color:#64748b; font-size:12px; }
|
||||
button,input,select,textarea { font:inherit; box-sizing:border-box; }
|
||||
button { display:inline-flex; align-items:center; justify-content:center; gap:4px; border:1px solid #dbe3ec; border-radius:8px; background:white; color:#334155; padding:6px 10px; min-height:32px; cursor:pointer; }
|
||||
button:hover:enabled { background:#f1f5f9; border-color:#94a3b8; }
|
||||
button:disabled { opacity:.5; cursor:default; }
|
||||
button.primary { background:#0f172a; color:white; border-color:#0f172a; }
|
||||
.material-symbols-outlined { font-size:18px; }
|
||||
label { display:flex; flex-direction:column; gap:6px; color:#475569; margin:8px 0; }
|
||||
input,select,textarea { width:100%; min-width:0; border:1px solid #cbd5e1; border-radius:8px; padding:8px 10px; background:white; color:#0f172a; }
|
||||
textarea { resize:vertical; line-height:1.6; }
|
||||
input:focus,select:focus,textarea:focus { outline:2px solid #94a3b8; outline-offset:1px; }
|
||||
details { border:1px solid #e2e8f0; border-radius:10px; padding:10px 12px; }
|
||||
summary { cursor:pointer; font-size:12px; color:#475569; }
|
||||
.session-summary,.case-editor { padding:12px; border:1px solid #e2e8f0; border-radius:12px; background:#f8fafc; }
|
||||
.session-summary .actions { margin-top:10px; }
|
||||
.conversation { display:flex; flex-direction:column; gap:12px; }
|
||||
.message { padding:10px 12px; border-radius:12px; background:#f1f5f9; border:1px solid transparent; }
|
||||
.message.user { margin-left:20px; background:#ecfdf5; border-color:#d1fae5; }
|
||||
.message.system { padding:4px 8px; background:transparent; color:#64748b; font-size:12px; }
|
||||
.recommendation { border-left:2px solid #94a3b8; padding-left:10px; line-height:1.6; }
|
||||
.recommendation button { margin:6px 0 0 8px; }
|
||||
.composer { border-top:1px solid #e2e8f0; padding-top:4px; }
|
||||
.composer > button { margin-top:8px; }
|
||||
.manual-control { padding:10px 12px; border:1px solid #dbe3ec; border-radius:10px; background:#fafcfd; }
|
||||
.manual-control label { flex:1; margin:0; }
|
||||
.manual-control button { align-self:flex-end; }
|
||||
.demo { border:1px solid #bbf7d0; border-radius:12px; padding:12px; background:#f0fdf4; }
|
||||
.demo.warning { background:#fffbeb; border-color:#fcd34d; }
|
||||
.record-dot { display:inline-block; width:7px; height:7px; border-radius:50%; background:#94a3b8; margin-right:6px; }
|
||||
.record-dot.recording { background:#dc2626; animation:pulse 1.8s infinite; }
|
||||
@keyframes pulse { 50% { opacity:.4; } }
|
||||
@media(prefers-reduced-motion:reduce) { .record-dot.recording { animation:none; } }
|
||||
.feedback { padding:9px 12px; background:#f0fdf4; border-radius:8px; font-size:12px; }
|
||||
.error { color:#b91c1c; }
|
||||
.feedback.error { background:#fef2f2; }
|
||||
.run,.demo-history { padding:10px 0; border-bottom:1px solid #e2e8f0; }
|
||||
.run-link { border:none; padding-left:0; background:transparent; text-align:left; }
|
||||
video { width:100%; max-height:260px; margin-top:8px; background:#0f172a; }
|
||||
a { color:#0f766e; font-size:12px; }
|
||||
.empty { color:#64748b; text-align:center; padding:25px 10px; }
|
||||
.empty .material-symbols-outlined { font-size:32px; }
|
||||
.settings-fields,.case-editor { display:flex; flex-direction:column; gap:3px; }
|
||||
|
||||
app-agent-stream {flex:1;min-height:0;display:block}
|
||||
.composer {flex:none;margin:0 20px 16px;padding:12px 16px;background:#fff;border:1px solid #dbe3ec;border-radius:16px;box-shadow:0 4px 18px #0f172a06;max-height:45%;overflow:auto}
|
||||
.composer label {margin:0 0 6px}.composer textarea {resize:none}.composer-actions {margin-top:8px}.composer-actions small {flex:1}
|
||||
.session-summary {margin-bottom:18px;background:#fff}.session-summary p {margin:6px 0}.session-summary .row strong {display:flex;align-items:center;gap:6px}
|
||||
.execution-history {margin-top:8px;border:0;padding:4px 0}.case-editor {margin:16px 0;background:#fff}.case-compact {display:flex;flex-wrap:wrap;align-items:center;gap:8px}.case-compact .actions {margin-left:auto}
|
||||
.manual-control {padding:0;margin-bottom:8px;border:0;background:transparent;justify-content:flex-end}.manual-control label {flex:none;flex-direction:row;align-items:center}.manual-control select {width:auto}.demo {padding:8px 10px;margin-bottom:8px}.demo p {margin:4px 0}
|
||||
|
||||
.composer {margin:0 24px 18px;padding:14px 18px;border-radius:22px;box-shadow:0 6px 24px #0f172a08;max-height:50%;box-sizing:border-box;min-width:0}
|
||||
.composer-heading {display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:4px}
|
||||
.composer-heading label {margin:0;font-size:12px;color:#64748b}
|
||||
.composer-status {font-size:11px;border-radius:20px;padding:3px 8px;background:#eef2ff;color:#4f46e5}
|
||||
.composer-status.waiting {color:#92400e;background:#fef3c7}
|
||||
.composer textarea {border:0;border-radius:0;padding:10px 0;background:transparent;min-height:66px;max-height:140px;font-size:14px;box-shadow:none}
|
||||
.composer textarea:focus {outline:none}
|
||||
.composer:focus-within {border-color:#a5b4fc;box-shadow:0 0 0 3px #eef2ff}
|
||||
.composer-actions {gap:6px;flex-wrap:wrap;border-top:1px solid #f1f5f9;padding-top:10px}
|
||||
.composer-actions .tool-button {border:0;background:#f8fafc;color:#64748b;border-radius:10px;padding:6px 9px}
|
||||
.composer-actions .send-hint {flex:1;text-align:right;font-size:10px;color:#94a3b8;min-width:100px}
|
||||
.send-button {min-height:36px;border-radius:12px;padding:7px 12px;background:#4f46e5!important;border-color:#4f46e5!important;gap:6px}
|
||||
.composer-footnote {font-size:11px;color:#94a3b8;margin:8px 0 0}
|
||||
.composer.needs-answer {border-color:#f4c97a}
|
||||
.question-banner {display:flex;gap:10px;background:#fffbeb;border:1px solid #fde68a;border-radius:12px;padding:12px;margin-bottom:12px;color:#92400e}
|
||||
.question-banner > .material-symbols-outlined {font-size:22px;margin-top:2px}.question-banner p {margin:4px 0;font-size:13px}.question-banner strong {font-size:13px}.question-banner button {margin-top:5px;background:#fff7d6;border-color:#f4c97a;color:#92400e}
|
||||
.case-editor {border-color:#c7d2fe;background:linear-gradient(135deg,#f5f7ff,#fff);padding:20px;border-radius:18px}
|
||||
.case-title {display:flex;align-items:center;gap:8px;color:#4f46e5;font-size:14px}.case-title .material-symbols-outlined {font-size:22px}
|
||||
.case-compact {display:block}.case-compact h3 {font-size:18px;line-height:1.5;color:#1e293b;margin:14px 0 6px}.case-compact small {font-size:11px;font-weight:400;background:#e0e7ff;color:#4338ca;padding:3px 6px;border-radius:5px;white-space:nowrap}.case-compact .actions {margin:16px 0 0}.case-compact .primary {background:#4f46e5;border-color:#4f46e5}
|
||||
.session-summary {padding:12px 16px;border-radius:14px}.session-summary p {color:#64748b;font-size:12px}
|
||||
@media(max-width:600px) {.composer {margin:0 12px 12px;padding:12px}.send-hint {display:none}.send-button {margin-left:auto}.question-banner {max-height:160px;overflow:auto}.case-editor {padding:14px}}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
|
||||
import { ExplorationPanelComponent } from './exploration-panel.component';
|
||||
import { ExplorationService, ExplorationSession } from '../../services/exploration.service';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
|
||||
import { AgentStreamComponent } from '../agent-stream/agent-stream.component';
|
||||
@Component({selector:'app-agent-stream',standalone:true,template:'<ng-content></ng-content>'}) class StreamStub {conversationMessages=input<any[]>([]);demonstrations=input<any[]>([]);pendingQuestion=input('');useRecommendation=output<string>();}
|
||||
|
||||
describe('Exploration conversation', () => {
|
||||
const config = {model: 'gpt-6.1-sol', reasoning_effort: 'high', timeout_seconds: 1800, demonstration_timeout_seconds: 900, demonstration_warning_seconds: 60};
|
||||
let current: ExplorationSession;
|
||||
let api: any;
|
||||
beforeEach(() => {
|
||||
current = {id:'s', goal:'检查设置', status:'running', config, active_run_id:'run', active_demonstration_id:null, case_version:0, message_count:0, elapsed_seconds:10, continuation_count:0, messages:[], runs:[], case:null, demonstrations:[], generations:[]};
|
||||
api = {drafts: new Map(),caseDrafts:new Map(),requests:new Map(),settings: jasmine.createSpy().and.resolveTo(config), list:jasmine.createSpy().and.resolveTo([]), get:jasmine.createSpy().and.callFake(async () => structuredClone(current)), act:jasmine.createSpy().and.resolveTo({}), version:jasmine.createSpy(), saveSettings:jasmine.createSpy().and.resolveTo(config)};
|
||||
TestBed.configureTestingModule({imports:[ExplorationPanelComponent], providers:[
|
||||
{provide:ExplorationService,useValue:api},
|
||||
{provide:AgentService,useValue:{selectSession:jasmine.createSpy(),fetchSessions:jasmine.createSpy(),fetchStatus:jasmine.createSpy(),currentSessionId:signal('run')}}
|
||||
]}).overrideComponent(ExplorationPanelComponent,{remove:{imports:[AgentStreamComponent]},add:{imports:[StreamStub]}});
|
||||
});
|
||||
function render() {
|
||||
const fixture = TestBed.createComponent(ExplorationPanelComponent);
|
||||
fixture.componentRef.setInput('sessionId','s');
|
||||
fixture.detectChanges(); flushMicrotasks(); fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
it('highlights unanswered questions beside the composer and hides unfinished cases', fakeAsync(() => {
|
||||
current.status='waiting_user'; current.pending_question='q';
|
||||
current.messages=[{id:'q',sequence:1,role:'assistant',kind:'question',text:'下一步是否回到主页?',created_at:1}];
|
||||
current.case={version:1,name:'旧用例',goal:'旧目标',preconditions:'',evidence:[]};current.case_version=1;
|
||||
const fixture=render();
|
||||
expect(fixture.nativeElement.querySelector('.question-banner').textContent).toContain('下一步是否回到主页');
|
||||
expect(fixture.nativeElement.querySelector('[aria-label="可复用用例"]')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).not.toContain('继续探索');
|
||||
current.active_run_id=null;current.status='completed';current.pending_question=undefined;
|
||||
void fixture.componentInstance.refresh();flushMicrotasks();fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('[aria-label="可复用用例"]').textContent).toContain('旧用例');
|
||||
expect(fixture.nativeElement.querySelector('.question-banner')).toBeNull();fixture.destroy();
|
||||
}));
|
||||
it('keeps a draft and disables guidance while the verified run is being synthesized', fakeAsync(() => {
|
||||
current.status='finalizing';
|
||||
const fixture=render();fixture.componentInstance.text='返回主页';
|
||||
void fixture.componentInstance.send();flushMicrotasks();
|
||||
expect(api.act).not.toHaveBeenCalled();expect(fixture.componentInstance.text).toBe('返回主页');
|
||||
expect(fixture.nativeElement.querySelector('textarea').disabled).toBeTrue();fixture.destroy();
|
||||
}));
|
||||
it('only invites manual input when the recorder is ready, and warns before its deadline', fakeAsync(() => {
|
||||
current.active_demonstration_id='demo';
|
||||
current.demonstrations=[{id:'demo',run_id:'run',source:'android',status:'requested',warning_seconds:60,media:[]}];
|
||||
const fixture=render();
|
||||
expect(fixture.nativeElement.textContent).toContain('请等“录制中”出现后再操作');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('现在可以操作设备');
|
||||
current.demonstrations[0]={...current.demonstrations[0],status:'recording',deadline:Date.now()/1000+30};
|
||||
void fixture.componentInstance.refresh(); flushMicrotasks(); fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('现在可以操作设备');
|
||||
expect(fixture.nativeElement.querySelector('.demo.warning')).not.toBeNull();
|
||||
fixture.destroy();
|
||||
}));
|
||||
it('shows budget exhaustion as a resumable pause, never a failed test', fakeAsync(() => {
|
||||
current.status='paused'; current.pause_reason='execution_budget'; current.active_run_id=null;
|
||||
current.case={version:1,name:'旧用例',goal:'旧目标',preconditions:'',evidence:[]};
|
||||
const fixture=render();
|
||||
expect(fixture.nativeElement.textContent).toContain('不计为测试失败');
|
||||
expect(fixture.nativeElement.querySelector('[aria-label="可复用用例"]')).toBeNull();
|
||||
const button=[...fixture.nativeElement.querySelectorAll('button')].find((b:any)=>b.textContent.includes('继续探索')) as HTMLButtonElement;
|
||||
button.click(); flushMicrotasks();
|
||||
expect(api.act.calls.mostRecent().args[1]).toBe('continue');
|
||||
fixture.destroy();
|
||||
}));
|
||||
it('retains the request ID and unsent text when a send response is lost', fakeAsync(() => {
|
||||
const fixture=render();
|
||||
api.act.and.rejectWith(new Error('network'));
|
||||
fixture.componentInstance.text='改用右上角入口';
|
||||
void fixture.componentInstance.send(); flushMicrotasks();
|
||||
const first=api.act.calls.mostRecent().args[3];
|
||||
expect(fixture.componentInstance.text).toBe('改用右上角入口');
|
||||
api.act.and.resolveTo({});
|
||||
void fixture.componentInstance.send(); flushMicrotasks();
|
||||
expect(api.act.calls.mostRecent().args[3]).toBe(first);
|
||||
expect(fixture.componentInstance.text).toBe('');
|
||||
fixture.destroy();
|
||||
}));
|
||||
it('preserves unsaved edits across polling, but clears them when choosing another conversation', fakeAsync(() => {
|
||||
current.case_version=1; current.case={version:1,name:'检查',goal:'旧目标',preconditions:'',evidence:[]};
|
||||
const fixture=render();
|
||||
fixture.componentInstance.editGoal='用户草稿'; fixture.componentInstance.dirty=true;
|
||||
current.case={...current.case,version:2,goal:'其他窗口保存'}; current.case_version=2;
|
||||
void fixture.componentInstance.refresh(); flushMicrotasks();
|
||||
expect(fixture.componentInstance.editGoal).toBe('用户草稿');
|
||||
current={...current,id:'other',case:null,case_version:0}; fixture.componentRef.setInput('sessionId','other');
|
||||
fixture.detectChanges(); flushMicrotasks(); fixture.detectChanges();
|
||||
expect(fixture.componentInstance.editGoal).toBe('');
|
||||
expect(api.caseDrafts.get('s')?.goal).toBe('用户草稿');
|
||||
fixture.destroy();
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Component, ChangeDetectionStrategy, DestroyRef, effect, inject, signal, untracked, input } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AgentStreamComponent } from '../agent-stream/agent-stream.component';
|
||||
import { ExplorationService, ExplorationSession, ExplorationMessage, Demonstration } from '../../services/exploration.service';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-exploration-panel', standalone: true,
|
||||
imports: [FormsModule, AgentStreamComponent, TestResultBadgeComponent],
|
||||
templateUrl: './exploration-panel.component.html', styleUrl: './exploration-panel.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ExplorationPanelComponent {
|
||||
api = inject(ExplorationService);
|
||||
agent = inject(AgentService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
session = signal<ExplorationSession | null>(null);
|
||||
messages = signal<ExplorationMessage[]>([]);
|
||||
loading = signal(false);
|
||||
busy = signal(false);
|
||||
error = signal('');
|
||||
notice = signal('');
|
||||
now = signal(Date.now() / 1000);
|
||||
sessionId = input.required<string>();
|
||||
editorOpen = signal(false);
|
||||
rerunOpen = signal(false);
|
||||
demoOptionsOpen = signal(false);
|
||||
get text() { return this.api.drafts.get(this.sessionId()) || ''; }
|
||||
set text(value: string) { this.api.drafts.set(this.sessionId(), value); }
|
||||
source = 'android';
|
||||
editName = '';
|
||||
editGoal = '';
|
||||
editPreconditions = '';
|
||||
editVersion = 0;
|
||||
dirty = false;
|
||||
rerunProfile = 'flash';
|
||||
rerunModel = '';
|
||||
rerunReasoning = 'high';
|
||||
private polling = false;
|
||||
private refreshSerial = 0;
|
||||
private followsLatest = true;
|
||||
private requests = this.api.requests;
|
||||
private displayedId: string | null = null;
|
||||
readonly models = ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-luna', 'mimo-v2.6-pro', 'mimo-v2.6-flash'];
|
||||
readonly efforts = ['default', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const id = this.sessionId();
|
||||
untracked(() => {
|
||||
this.rememberDraft(); this.displayedId = id;
|
||||
this.session.set(null); this.messages.set([]); this.dirty = false; this.editVersion = 0; this.followsLatest = true;
|
||||
this.editName = ''; this.editGoal = ''; this.editPreconditions = '';
|
||||
this.editorOpen.set(false); this.rerunOpen.set(false); this.error.set(''); this.notice.set('');
|
||||
const draft = this.api.caseDrafts.get(id);
|
||||
if (draft) { this.loadCase(draft); this.dirty = true; this.editorOpen.set(true); }
|
||||
if (id) void this.refresh(id);
|
||||
});
|
||||
});
|
||||
const interval = setInterval(() => {
|
||||
this.now.set(Date.now() / 1000);
|
||||
const id = this.sessionId();
|
||||
if (id && !this.polling) void this.refresh(id, false);
|
||||
}, 2000);
|
||||
this.destroyRef.onDestroy(() => { this.rememberDraft(); this.refreshSerial++; clearInterval(interval); });
|
||||
}
|
||||
|
||||
private rememberDraft() {
|
||||
if (!this.displayedId) return;
|
||||
if (this.dirty) this.api.caseDrafts.set(this.displayedId, {name:this.editName,goal:this.editGoal,preconditions:this.editPreconditions,version:this.editVersion});
|
||||
else this.api.caseDrafts.delete(this.displayedId);
|
||||
}
|
||||
async refresh(id = this.sessionId(), initial = true) {
|
||||
if (!id || this.destroyRef.destroyed) return;
|
||||
const serial = ++this.refreshSerial;
|
||||
if (initial) this.loading.set(true);
|
||||
this.polling = true;
|
||||
try {
|
||||
const current = this.session();
|
||||
const after = current?.id === id ? (this.messages().at(-1)?.sequence || 0) : 0;
|
||||
const value = await this.api.get(id, after);
|
||||
if (id !== this.sessionId() || serial !== this.refreshSerial) return;
|
||||
const combined = new Map((current?.id === id ? this.messages() : []).map(m => [m.id, m]));
|
||||
value.messages.forEach(m => combined.set(m.id, m));
|
||||
this.messages.set([...combined.values()].sort((a, b) => a.sequence - b.sequence));
|
||||
this.session.set(value);
|
||||
if (!current) this.source = value.task_config?.['execution_target'] === 'computer' ? 'computer' : 'android';
|
||||
if (value.case && this.followsLatest && !this.dirty && this.editVersion !== value.case.version) this.loadCase(value.case);
|
||||
} catch (e) { if (id === this.sessionId()) this.showError(e); }
|
||||
finally { if (serial === this.refreshSerial) { this.polling = false; this.loading.set(false); } }
|
||||
}
|
||||
private loadCase(value: {name: string; goal: string; preconditions: string; version: number}) {
|
||||
this.editName = value.name; this.editGoal = value.goal; this.editPreconditions = value.preconditions;
|
||||
this.editVersion = value.version; this.dirty = false;
|
||||
}
|
||||
async selectVersion(version: string) {
|
||||
const id = this.sessionId(); if (!id || this.dirty) return;
|
||||
this.followsLatest = Number(version) === this.session()?.case_version;
|
||||
try { const value = await this.api.version(id, Number(version)); if (id === this.sessionId()) this.loadCase(value); }
|
||||
catch(e) { this.showError(e); }
|
||||
}
|
||||
versions() { return Array.from({length: this.session()?.case_version || 0}, (_, i) => i + 1); }
|
||||
label(status: string) {
|
||||
return ({draft:'未开始', queued:'等待执行', reserved:'等待执行', running:'探索中', waiting_user:'等待你回答', demonstrating:'人工示范', analyzing_demo:'分析示范', finalizing:'正在提炼用例', paused:'已暂停,可继续', completed:'探索已结束', cancelled:'已取消', failed:'执行失败', interrupted:'已中断,可继续', requested:'等待安全接管', recording:'录制中', analyzing:'分析中', timed_out:'示范到时,已保存'} as Record<string,string>)[status] || status;
|
||||
}
|
||||
pendingQuestion() { return this.messages().find(m => m.id === this.session()?.pending_question); }
|
||||
finalizing() { return this.session()?.status === 'finalizing'; }
|
||||
showCase() { const s = this.session(); return !!s && !s.active_run_id && ['completed', 'cancelled'].includes(s.status); }
|
||||
activeDemo() { const s = this.session(); return s?.demonstrations.find(d => d.id === s.active_demonstration_id); }
|
||||
activeValidation() { const s = this.session(); return s?.runs.some(r => r.id === s.active_run_id && r.kind === 'validation') || false; }
|
||||
secondsLeft(demo: Demonstration) { return Math.max(0, Math.ceil((demo.deadline || this.now()) - this.now())); }
|
||||
async action(action: string, payload: Record<string, unknown> = {}): Promise<Record<string, unknown> | null> {
|
||||
const id = this.sessionId(); if (!id || this.busy()) return null;
|
||||
const selectedRun = this.agent.currentSessionId();
|
||||
const key = JSON.stringify([id, action, payload]);
|
||||
const requestId = this.requests.get(key) || crypto.randomUUID(); this.requests.set(key, requestId);
|
||||
this.busy.set(true); this.error.set(''); this.notice.set('');
|
||||
try {
|
||||
const value = await this.api.act(id, action, payload, requestId);
|
||||
this.requests.delete(key);
|
||||
await this.refresh(id, false);
|
||||
const run = value['run'] as {id?: string} | undefined;
|
||||
if (id === this.sessionId() && selectedRun === this.agent.currentSessionId() && run?.id) { this.agent.selectSession(run.id, true); }
|
||||
this.agent.fetchSessions(); this.agent.fetchStatus();
|
||||
return id === this.sessionId() ? value : null;
|
||||
} catch (e) { if (id === this.sessionId()) this.showError(e); return null; }
|
||||
finally { this.busy.set(false); }
|
||||
}
|
||||
async send() {
|
||||
const text = this.text.trim(), id = this.sessionId(); if (!text || this.activeValidation() || this.finalizing()) return;
|
||||
if (await this.action('messages', {text}) && this.api.drafts.get(id)?.trim() === text) this.api.drafts.delete(id);
|
||||
}
|
||||
demoAction(action: string) {
|
||||
const demo = this.activeDemo();
|
||||
if (demo) void this.action(`demonstrations/${demo.id}/${action}`, action === 'extend' ? {seconds: this.session()?.config.demonstration_timeout_seconds || 900} : {});
|
||||
}
|
||||
onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); void this.send(); }
|
||||
}
|
||||
async saveCase() {
|
||||
if (!this.editName.trim() || !this.editGoal.trim()) return;
|
||||
const result = await this.action('case', {expected_version: this.editVersion, case: {name: this.editName, goal: this.editGoal, preconditions: this.editPreconditions, evidence: this.session()?.case?.evidence || []}});
|
||||
if (result) { this.dirty = false; this.followsLatest = true; await this.refresh(); this.notice.set('用例已保存为新版本'); }
|
||||
}
|
||||
generate() {
|
||||
const source = [...(this.session()?.runs || [])].reverse().find(r => r.kind === 'exploration' && r.test_result?.status === 'passed' && r.status === 'completed');
|
||||
if (source) void this.action('generate', {source_run_id: source.id, expected_version: this.session()?.case_version || 0});
|
||||
else this.error.set('尚无验证通过的探索执行,请继续探索;也可以手动保存草稿。');
|
||||
}
|
||||
generating() { return this.session()?.generations.some(g => g.status === 'running') || false; }
|
||||
rerun(action = 'validate') {
|
||||
const task_config: Record<string, unknown> = {};
|
||||
if (this.rerunModel.trim()) { task_config['model'] = this.rerunModel.trim(); task_config['reasoning_effort'] = this.rerunReasoning; }
|
||||
const payload: Record<string, unknown> = {version: this.editVersion, profile: this.rerunProfile, task_config};
|
||||
if (action === 'batch') payload['name'] = this.editName;
|
||||
void this.action(action, payload).then(value => { if (value && action === 'batch') this.notice.set('已创建批次草稿,可在“报告与批量”中查看并启动。'); });
|
||||
}
|
||||
async copyCase() {
|
||||
try { await navigator.clipboard.writeText(`${this.editPreconditions ? '前置条件:' + this.editPreconditions + '\n\n' : ''}${this.editGoal}`); this.notice.set('用例提示词已复制'); }
|
||||
catch { this.error.set('剪贴板不可用,请选中下方提示词复制'); }
|
||||
}
|
||||
mediaUrl(runId: string, path: string) { return '/videos/' + [runId, ...path.split('/')].map(encodeURIComponent).join('/'); }
|
||||
private showError(error: unknown) { const e = error as {error?: {detail?: unknown}; message?: string}; this.error.set(typeof e.error?.detail === 'string' ? e.error.detail : e.message || '操作失败,请刷新状态后重试'); }
|
||||
}
|
||||
@@ -84,6 +84,12 @@ interface Capabilities {
|
||||
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; }
|
||||
.number-fields label { display: block; padding: 0; color: #334155; }
|
||||
.number-fields input, .number-fields select { box-sizing: border-box; display: block; width: 100%; height: auto; margin-top: 8px; padding: 9px; border: 1px solid #cbd5e1; border-radius: 8px; font: inherit; }
|
||||
section {font-size:13px;border-color:#e2e8f0}h3 {font-size:15px;color:#334155}
|
||||
section > label {align-items:center;flex-direction:row-reverse;justify-content:space-between;border-bottom:1px solid #f1f5f9;gap:20px}section > label > span {flex:1}small {font-size:12px}
|
||||
input[role=switch] {appearance:none;position:relative;width:36px;height:22px;border:1px solid #cbd5e1;border-radius:20px;background:#e2e8f0;cursor:pointer;transition:background .15s}
|
||||
input[role=switch]::after {content:'';position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 3px #0f172a25;transition:transform .15s}
|
||||
input[role=switch]:checked {background:#6366f1;border-color:#6366f1}input[role=switch]:checked::after {transform:translateX(14px)}input[role=switch]:focus-visible {outline:2px solid #a5b4fc;outline-offset:3px}input:disabled {opacity:.5;cursor:default}
|
||||
button {background:#4f46e5;border-color:#4f46e5;border-radius:12px;font-weight:600}.execution-settings {border-color:#e2e8f0;border-radius:14px;padding:14px 16px}.number-fields input,.number-fields select {background:#fff;border-color:#dbe3ec}
|
||||
@media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }`]
|
||||
})
|
||||
export class CapabilitySettingsComponent implements OnInit {
|
||||
|
||||
@@ -71,9 +71,9 @@ describe('持久化模型与功能设置', () => {
|
||||
expect(input.autocomplete).toBe('off');
|
||||
input.value = 'custom/model-v2';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
const reasoning = root.querySelector<HTMLSelectElement>('#model-reasoning')!;
|
||||
reasoning.value = 'max';
|
||||
reasoning.dispatchEvent(new Event('change'));
|
||||
const reasoning = root.querySelector<HTMLInputElement>('input[aria-label="思考强度"]')!;
|
||||
reasoning.value = '7';
|
||||
reasoning.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(presets[0].getAttribute('aria-pressed')).toBe('false');
|
||||
@@ -90,7 +90,7 @@ describe('持久化模型与功能设置', () => {
|
||||
reopened.detectChanges();
|
||||
await reopened.whenStable();
|
||||
expect(reopened.nativeElement.querySelector('#model-name').value).toBe('custom/model-v2');
|
||||
expect(reopened.nativeElement.querySelector('#model-reasoning').value).toBe('max');
|
||||
expect(reopened.nativeElement.querySelector('input[aria-label="思考强度"]').getAttribute('aria-valuetext')).toBe('最高 · max');
|
||||
});
|
||||
|
||||
it('高级稳定性设置可编辑并保存', async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, OnInit, inject, signal, output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { finalize } from 'rxjs';
|
||||
import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from '../setting-slider/setting-slider.component';
|
||||
|
||||
interface ModelSettings {
|
||||
base_url: string;
|
||||
@@ -24,7 +25,7 @@ interface ModelSettings {
|
||||
@Component({
|
||||
selector: 'app-model-settings',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
imports: [FormsModule, SettingSliderComponent],
|
||||
template: `
|
||||
<form class="model-settings" (ngSubmit)="save()">
|
||||
<p>统一使用 OpenAI Responses。保存后新任务立即生效,重启后自动加载。</p>
|
||||
@@ -43,14 +44,7 @@ interface ModelSettings {
|
||||
[(ngModel)]="model" required placeholder="输入接口支持的模型名" aria-describedby="model-name-help"
|
||||
[disabled]="busy() || loading()" />
|
||||
<small id="model-name-help">点击预设填入,或直接输入自定义模型名称;保存后可继续使用。</small>
|
||||
<label for="model-reasoning">思考强度</label>
|
||||
<select id="model-reasoning" name="reasoning" [(ngModel)]="reasoning" [disabled]="busy() || loading()">
|
||||
<option value="default">由模型默认</option><option value="none">关闭(none)</option>
|
||||
<option value="minimal">最低(minimal)</option><option value="low">低(low)</option>
|
||||
<option value="medium">中(medium)</option><option value="high">高(high)</option>
|
||||
<option value="xhigh">极高(xhigh)</option><option value="max">最高(max)</option>
|
||||
</select>
|
||||
<small>接口需支持所选强度;不支持时会明确报错,可手动选择“由模型默认”。</small>
|
||||
<app-setting-slider label="思考强度" [(value)]="reasoning" [values]="reasoningValues" [labels]="reasoningLabels" [disabled]="!!busy() || loading()" hint="更高的强度适合复杂推理;需接口支持,也可选择模型默认。" />
|
||||
<label for="model-key">API Key <span>{{ keyConfigured() ? '(已配置,留空保留)' : '(尚未配置)' }}</span></label>
|
||||
<input id="model-key" name="apiKey" type="password" [(ngModel)]="apiKey" autocomplete="new-password"
|
||||
placeholder="输入新密钥" [disabled]="busy() || loading()" />
|
||||
@@ -69,13 +63,7 @@ interface ModelSettings {
|
||||
<label>兜底模型名称<input name="toolFallbackModel" type="text" required autocomplete="off"
|
||||
autocapitalize="off" spellcheck="false" [(ngModel)]="toolFallbackModel"
|
||||
[disabled]="busy() || loading()" /></label>
|
||||
<label>兜底思考强度<select name="toolFallbackReasoning" [(ngModel)]="toolFallbackReasoning"
|
||||
[disabled]="busy() || loading()">
|
||||
<option value="default">由模型默认</option><option value="none">关闭(none)</option>
|
||||
<option value="minimal">最低(minimal)</option><option value="low">低(low)</option>
|
||||
<option value="medium">中(medium)</option><option value="high">高(high)</option>
|
||||
<option value="xhigh">极高(xhigh)</option><option value="max">最高(max)</option>
|
||||
</select></label>
|
||||
<app-setting-slider label="兜底思考强度" [(value)]="toolFallbackReasoning" [values]="reasoningValues" [labels]="reasoningLabels" [disabled]="!!busy() || loading()" />
|
||||
<div class="number-fields">
|
||||
<label>兜底请求重试次数<input name="toolFallbackRetries" type="number" min="0" max="10" required
|
||||
[(ngModel)]="toolFallbackRetries" [disabled]="busy() || loading()" /></label>
|
||||
@@ -119,9 +107,16 @@ interface ModelSettings {
|
||||
.model-presets button[aria-pressed="true"] { background: #e0f2fe; border-color: #0284c7; color: #075985; }
|
||||
button:focus-visible { outline: 2px solid #0284c7; outline-offset: 2px; }
|
||||
small { overflow-wrap: anywhere; } @media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }
|
||||
.model-settings {gap:12px;font-size:13px} label {margin-top:6px}input,select {background:#fff;border-color:#dbe3ec;padding:11px 12px}
|
||||
input:focus,select:focus {outline:2px solid #a5b4fc;outline-offset:2px}
|
||||
.actions {border-top:1px solid #e2e8f0;padding-top:16px;align-items:center}.actions button {min-height:40px;background:#4f46e5;border-color:#4f46e5;font-weight:600;border-radius:12px}.actions button[type=button] {background:#fff;border-color:#dbe3ec;color:#475569}
|
||||
.model-presets button {border-radius:20px;font-size:12px}.model-presets button[aria-pressed=true] {background:#eef2ff;border-color:#a5b4fc;color:#4338ca}
|
||||
.recovery-settings {border-color:#e2e8f0;border-radius:14px;padding:14px 16px}.recovery-settings summary {font-size:13px}
|
||||
`]
|
||||
})
|
||||
export class ModelSettingsComponent implements OnInit {
|
||||
readonly reasoningValues = REASONING_VALUES;
|
||||
readonly reasoningLabels = REASONING_LABELS;
|
||||
private http = inject(HttpClient);
|
||||
saved = output<void>();
|
||||
modelPresets: string[] = [];
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from './setting-slider.component';
|
||||
|
||||
describe('Setting slider', () => {
|
||||
it('maps the selected position to the exact request value and exposes its readable label', () => {
|
||||
const f=TestBed.createComponent(SettingSliderComponent);
|
||||
f.componentRef.setInput('label','思考强度');f.componentRef.setInput('values',REASONING_VALUES);f.componentRef.setInput('labels',REASONING_LABELS);f.componentRef.setInput('value','high');f.detectChanges();
|
||||
const slider=f.nativeElement.querySelector('input');expect(slider.getAttribute('aria-valuetext')).toBe('高 · high');
|
||||
const emit=spyOn(f.componentInstance.valueChange,'emit');slider.value='7';slider.dispatchEvent(new Event('input'));
|
||||
expect(emit).toHaveBeenCalledWith('max');f.destroy();
|
||||
});
|
||||
it('preserves a saved custom time until the slider is actually changed', () => {
|
||||
const f=TestBed.createComponent(SettingSliderComponent);const emit=spyOn(f.componentInstance.valueChange,'emit');
|
||||
f.componentRef.setInput('label','执行时限');f.componentRef.setInput('values',[60,300,1800]);f.componentRef.setInput('value',125);f.detectChanges();
|
||||
expect(f.nativeElement.querySelector('output').textContent).toBe('125 秒');expect(emit).not.toHaveBeenCalled();f.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-setting-slider', standalone: true,
|
||||
template: `
|
||||
<div class="slider-heading"><span>{{label()}}</span><output>{{display()}}</output></div>
|
||||
<input type="range" min="0" [max]="values().length - 1" step="1" [value]="index()"
|
||||
[disabled]="disabled()" [attr.aria-label]="label()" [attr.aria-valuetext]="display()"
|
||||
[style.--fill]="(index() / (values().length - 1) * 100) + '%'"
|
||||
(input)="change($any($event.target).value)" />
|
||||
<div class="slider-ends"><span>{{labels()[0] || values()[0]}}</span><span>{{labels().at(-1) || values().at(-1)}}</span></div>
|
||||
@if(hint()){<p>{{hint()}}</p>}
|
||||
`,
|
||||
styles: [`
|
||||
:host{display:block;min-width:0;padding:14px 16px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:14px;font-size:13px;color:#334155}
|
||||
.slider-heading,.slider-ends{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
||||
.slider-heading{font-weight:600}output{color:#4f46e5;font-size:12px;white-space:nowrap}.slider-ends{font-size:11px;color:#64748b}
|
||||
input{appearance:none;display:block;box-sizing:border-box;width:100%;height:6px;border:0;border-radius:10px;margin:20px 0 12px;background:linear-gradient(to right,#6366f1 var(--fill),#dbe3ee var(--fill));cursor:pointer}
|
||||
input::-webkit-slider-thumb{appearance:none;width:18px;height:18px;border-radius:50%;background:#fff;border:2px solid #6366f1;box-shadow:0 2px 6px #33415526}
|
||||
input::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#fff;border:2px solid #6366f1}
|
||||
input:focus-visible{outline:2px solid #a5b4fc;outline-offset:5px}input:disabled{opacity:.5;cursor:default}p{margin:10px 0 0;font-size:12px;line-height:1.6;color:#64748b}
|
||||
`]
|
||||
})
|
||||
export class SettingSliderComponent {
|
||||
label = input.required<string>();
|
||||
value = input.required<string | number>();
|
||||
values = input.required<(string | number)[]>();
|
||||
labels = input<string[]>([]);
|
||||
hint = input('');
|
||||
disabled = input(false);
|
||||
valueChange = output<any>();
|
||||
index = computed(() => {
|
||||
const exact = this.values().indexOf(this.value());
|
||||
if(exact >= 0) return exact;
|
||||
if(typeof this.value() === 'number') return this.values().reduce<number>((best, v, i) => Math.abs(Number(v)-Number(this.value())) < Math.abs(Number(this.values()[best])-Number(this.value())) ? i : best, 0);
|
||||
return 0;
|
||||
});
|
||||
display = computed(() => {
|
||||
const exact = this.values().indexOf(this.value());
|
||||
return exact >= 0 ? this.labels()[exact] || String(this.value()) : String(this.value()) + (typeof this.value() === 'number' ? ' 秒' : '');
|
||||
});
|
||||
change(index: string) { this.valueChange.emit(this.values()[Number(index)]); }
|
||||
}
|
||||
|
||||
export const REASONING_VALUES = ['default','none','minimal','low','medium','high','xhigh','max'];
|
||||
export const REASONING_LABELS = ['模型默认','关闭 · none','最低 · minimal','低 · low','中 · medium','高 · high','极高 · xhigh','最高 · max'];
|
||||
+2
-2
@@ -14,8 +14,8 @@ describe('队列批次筛选', () => {
|
||||
http.expectOne('/api/automation/batches').flush([{id:'batch-1',name:'第一轮冒烟',created_at:100},{id:'batch-2',name:'第二轮冒烟',created_at:200}]);
|
||||
fixture.detectChanges(); tick(); fixture.detectChanges();
|
||||
const select: HTMLSelectElement = fixture.nativeElement.querySelector('select');
|
||||
expect(select.value).toBe('batch-1'); expect(select.options.length).toBe(5);
|
||||
for(const value of ['__batch__','__single__','batch-2']){
|
||||
expect(select.value).toBe('batch-1'); expect(select.options.length).toBe(6);
|
||||
for(const value of ['__batch__','__single__','__exploration__','batch-2']){
|
||||
select.value=value; select.dispatchEvent(new Event('change')); tick(); fixture.detectChanges();
|
||||
expect(agent.batchFilter()).toBe(value);
|
||||
}
|
||||
|
||||
+2
-2
@@ -7,9 +7,9 @@ import { AgentService } from '../../services/agent.service';
|
||||
@Component({
|
||||
selector: 'app-task-batch-filter', standalone: true, imports: [FormsModule, DatePipe],
|
||||
template: `<div class="batch-filter"><label>任务范围<select aria-label="按批次筛选任务" [ngModel]="agent.batchFilter()" (ngModelChange)="agent.batchFilter.set($event)">
|
||||
<option value="">全部任务</option><option value="__batch__">全部批量任务</option><option value="__single__">单独任务</option>
|
||||
<option value="">全部任务</option><option value="__batch__">全部批量任务</option><option value="__single__">单独任务</option><option value="__exploration__">探索任务</option>
|
||||
@for(batch of batches(); track batch.id){<option [value]="batch.id">{{batch.name}} · {{batch.created_at * 1000 | date:'MM-dd HH:mm'}}</option>}
|
||||
@if(agent.batchFilter() && !['__batch__','__single__'].includes(agent.batchFilter()) && !hasSelectedBatch()){<option [value]="agent.batchFilter()">当前选定批次</option>}
|
||||
@if(agent.batchFilter() && !['__batch__','__single__','__exploration__'].includes(agent.batchFilter()) && !hasSelectedBatch()){<option [value]="agent.batchFilter()">当前选定批次</option>}
|
||||
</select></label>@if(agent.batchFilter()){<button type="button" (click)="agent.batchFilter.set('')">清除筛选</button>}</div>
|
||||
@if(error()){<div class="filter-error" role="alert">{{error()}} <button type="button" (click)="load()">重试</button></div>}`,
|
||||
styles: [`:host{display:block;margin:0 0 12px}.batch-filter{display:flex;align-items:flex-end;gap:8px;color:#475569;font-size:12px}label{flex:1;min-width:0}select{display:block;box-sizing:border-box;width:100%;height:34px;margin-top:5px;border:1px solid #cbd5e1;border-radius:7px;padding:0 9px;background:#fff;color:#1e293b;font:inherit}option{background:#fff;color:#1e293b}button{height:34px;white-space:nowrap;border:1px solid #cbd5e1;border-radius:7px;padding:0 9px;background:#fff;color:#475569;font:inherit;cursor:pointer}select:focus-visible,button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}.filter-error{font-size:12px;color:#b91c1c;margin-top:5px}.filter-error button{height:auto;padding:2px 6px}`]
|
||||
|
||||
@@ -15,6 +15,23 @@ describe('历史任务检索', () => {
|
||||
});
|
||||
afterEach(() => http.verify());
|
||||
|
||||
it('完成任务分别显示探索、Flash、Pro,模式不受通过或失败颜色影响', () => {
|
||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||
http.expectOne(r => r.url === '/api/automation/history').flush({total:3,items:[
|
||||
{session_id:'e',status:'completed',start_time:100,profile:'pro',exploration_kind:'exploration'},
|
||||
{session_id:'f',status:'failed',start_time:100,profile:'flash',exploration_kind:'validation',case_version:2},
|
||||
{session_id:'p',status:'completed',start_time:100,profile:'pro'}
|
||||
]});
|
||||
fixture.detectChanges();
|
||||
const badges = fixture.nativeElement.querySelectorAll('.mode-badge');
|
||||
expect(badges.length).toBe(3);
|
||||
expect(badges[0].textContent).toContain('探索');
|
||||
expect(badges[1].textContent).toContain('Flash');
|
||||
expect(badges[2].textContent).toContain('Pro');
|
||||
expect(fixture.nativeElement.textContent).toContain('用例复跑 · v2');
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('更换批次时保留搜索条件并回到第一页,复验后立即刷新', () => {
|
||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||
import { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-badge.component';
|
||||
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
||||
import { Component, DestroyRef, effect, inject, signal, untracked } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
@@ -7,14 +8,14 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
|
||||
@Component({
|
||||
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent],
|
||||
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent,TaskModeBadgeComponent],
|
||||
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small></h3>
|
||||
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
|
||||
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
|
||||
@if(error()){<p role="alert">{{error()}}</p>}
|
||||
@if(loading()){<small>正在查询…</small>}
|
||||
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
|
||||
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
|
||||
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" />@if(row.exploration_kind !== "exploration"){<button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>}
|
||||
</details>}
|
||||
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
|
||||
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TaskModeBadgeComponent } from './task-mode-badge.component';
|
||||
|
||||
describe('执行模式徽标', () => {
|
||||
it('探索优先于底层 Pro,复跑显示实际使用的 Flash / Pro', () => {
|
||||
const fixture = TestBed.createComponent(TaskModeBadgeComponent);
|
||||
const check = (session: object, mode: string, text: string) => {
|
||||
fixture.componentRef.setInput('session', session);
|
||||
fixture.detectChanges();
|
||||
const badge = fixture.nativeElement.querySelector('.mode-badge');
|
||||
expect(badge.classList.contains(mode)).toBeTrue();
|
||||
expect(badge.textContent).toContain(text);
|
||||
expect(badge.title).toBe('执行模式:' + text);
|
||||
};
|
||||
check({exploration_kind:'exploration',profile:'pro'}, 'explore', '探索');
|
||||
check({exploration_kind:'validation',profile:'flash'}, 'flash', 'Flash');
|
||||
check({profile:'pro'}, 'pro', 'Pro');
|
||||
check({model_info:{architecture:'ARTEMIS Flash'}}, 'flash', 'Flash');
|
||||
check({model_info:{id:'mimo-v2.6-pro'}}, 'unknown', '模式未记录');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
import { Session } from '../../core/models/session.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-task-mode-badge',
|
||||
standalone: true,
|
||||
template: `<span class="mode-badge" [class]="'mode-badge ' + mode()" [title]="'执行模式:' + label()"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
|
||||
styles: [`
|
||||
:host{display:inline-flex;vertical-align:middle}
|
||||
.mode-badge{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;gap:3px;padding:0 6px;border:1px solid #e2e8f0;border-radius:5px;background:#f8fafc;color:#475569;font-size:11px;font-weight:600;line-height:14px;white-space:nowrap}
|
||||
.material-symbols-outlined{font-size:13px;line-height:1}
|
||||
.explore{background:#eef2ff;border-color:#c7d2fe;color:#4338ca}
|
||||
.flash{background:#fff7ed;border-color:#fed7aa;color:#9a3412}
|
||||
.pro{background:#eff6ff;border-color:#bfdbfe;color:#1d4ed8}
|
||||
`]
|
||||
})
|
||||
export class TaskModeBadgeComponent {
|
||||
session = input.required<Partial<Session>>();
|
||||
mode = computed<'explore' | 'flash' | 'pro' | 'unknown'>(() => {
|
||||
const s = this.session();
|
||||
if (s.exploration_kind === 'exploration') return 'explore';
|
||||
const profile = (s.profile || s.model_info?.architecture || '').toLowerCase().replace(/^artemis\s+/, '');
|
||||
return profile === 'explore' || profile === 'flash' || profile === 'pro' ? profile : 'unknown';
|
||||
});
|
||||
label = computed(() => ({explore:'探索',flash:'Flash',pro:'Pro',unknown:'模式未记录'})[this.mode()]);
|
||||
icon = computed(() => ({explore:'explore',flash:'bolt',pro:'psychology',unknown:'help_outline'})[this.mode()]);
|
||||
}
|
||||
@@ -50,6 +50,13 @@ export interface TaskQueueItem {
|
||||
|
||||
export interface Session {
|
||||
session_id: string;
|
||||
profile?: string | null;
|
||||
exploration_id?: string;
|
||||
exploration_latest_run_id?: string;
|
||||
exploration_status?: string;
|
||||
exploration_run_count?: number;
|
||||
exploration_kind?: 'exploration' | 'validation';
|
||||
case_version?: number | null;
|
||||
batch_id?: string | null;
|
||||
initial_goal: string;
|
||||
start_time: number;
|
||||
|
||||
@@ -1423,8 +1423,10 @@
|
||||
<span class="mode-name">Flash</span>
|
||||
<span class="bullet">·</span>
|
||||
<span>节省 Token,快速响应 <strong>每步 3–5 秒</strong> 直接根据实时屏幕执行操作,适合路径明确的日常任务。</span>
|
||||
<span class="limitation-tag">(不规划、不检查结果、不执行 ADB 命令)</span>
|
||||
<span class="limitation-tag">(轻量执行,结束时判断测试结果)</span>
|
||||
</p>
|
||||
} @else if(selectedProfile() === 'exploration') {
|
||||
<p class="desc-line"><span class="mode-name">探索</span><span class="bullet">·</span><span>边执行边交流,支持人工示范,成功后生成可复用用例。</span></p>
|
||||
} @else {
|
||||
<p class="desc-line">
|
||||
<span class="mode-name">Pro</span>
|
||||
@@ -1468,6 +1470,9 @@
|
||||
<span class="material-symbols-outlined btn-icon">psychology</span>
|
||||
<span class="btn-text">Pro</span>
|
||||
</button>
|
||||
<button type="button" class="model-toggle-btn btn-pro" [class.active]="selectedProfile() === 'exploration'" (click)="setProfile('exploration')" title="探索:边执行边交流,可人工示范,完成后生成可复用用例">
|
||||
<span class="material-symbols-outlined btn-icon">explore</span><span class="btn-text">探索</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1488,13 +1493,36 @@
|
||||
@if (isSubmitting()) {
|
||||
<span class="spinner"></span><span>正在启动…</span>
|
||||
} @else {
|
||||
<span class="material-symbols-outlined run-icon">play_arrow</span><span>执行任务</span>
|
||||
<span class="material-symbols-outlined run-icon">play_arrow</span><span>{{selectedProfile() === 'exploration' ? '开始探索' : '执行任务'}}</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(selectedProfile() === 'exploration') {
|
||||
<section class="exploration-settings">
|
||||
<div class="exploration-intro"><span class="material-symbols-outlined">explore</span><div><strong>一起探索复杂用例</strong><p>描述大概流程,边执行边交流。可以纠正方向或亲自示范,结束后生成可复用的用例。</p></div></div>
|
||||
@if(explorationConfig(); as config) {
|
||||
<button type="button" class="exploration-settings-toggle" (click)="explorationSettingsOpen.set(!explorationSettingsOpen())" [attr.aria-expanded]="explorationSettingsOpen()"><span class="material-symbols-outlined">tune</span><span>{{config.model}} · {{config.reasoning_effort}}</span><span class="settings-toggle-label">调整探索设置</span><span class="material-symbols-outlined">{{explorationSettingsOpen() ? 'expand_less' : 'expand_more'}}</span></button>
|
||||
@if(explorationSettingsOpen()) {
|
||||
<div class="exploration-settings-body">
|
||||
<label class="model-label">探索模型<input list="explore-models" [(ngModel)]="config.model" [disabled]="explorationSettingsBusy()" /></label>
|
||||
<datalist id="explore-models"><option value="gpt-6.1-sol"></option><option value="gpt-6-astra"></option><option value="gpt-6-luna"></option><option value="mimo-v2.6-pro"></option></datalist>
|
||||
<div class="exploration-fields">
|
||||
<app-setting-slider label="思考强度" [(value)]="config.reasoning_effort" [values]="reasoningValues" [labels]="reasoningLabels" hint="越高越适合复杂推理,也可能等待更久。" [disabled]="explorationSettingsBusy()" />
|
||||
<app-setting-slider label="每段执行时限" [(value)]="config.timeout_seconds" [values]="executionTimes" [labels]="executionTimeLabels" hint="到时暂停并保存进度,点击继续即可接着探索。" [disabled]="explorationSettingsBusy()" />
|
||||
<app-setting-slider label="人工示范时限" [(value)]="config.demonstration_timeout_seconds" [values]="demoTimes" [labels]="demoTimeLabels" hint="点击“我来操作”后,允许录制的示范时长。" [disabled]="explorationSettingsBusy()" />
|
||||
<app-setting-slider label="示范提前提醒" [(value)]="config.demonstration_warning_seconds" [values]="warningTimes" [labels]="warningLabels" hint="示范即将到时时提醒,可手动延长。" [disabled]="explorationSettingsBusy()" />
|
||||
</div>
|
||||
<p class="settings-note">等待回答和人工示范不占执行时间。这组设置只用于探索。</p>
|
||||
@if(!explorationConfigValid()){<p role="alert" class="settings-error">请填写模型,并将提前提醒设为小于示范时限。</p>}
|
||||
<div class="settings-footer"><button type="button" class="save-settings" (click)="saveExplorationSettings()" [disabled]="explorationSettingsBusy() || !explorationConfigValid()">{{explorationSettingsBusy() ? '正在保存…' : '记住此配置'}}</button><span role="status">{{explorationNotice()}}</span></div>
|
||||
</div>
|
||||
}
|
||||
} @else {<button (click)="loadExplorationSettings()" [disabled]="explorationSettingsBusy()">{{explorationSettingsBusy() ? '正在读取配置…' : '重新读取探索配置'}}</button>}
|
||||
</section>
|
||||
}
|
||||
<!-- Pro Mode Standalone Expected Output Card (Clean & Minimalist) -->
|
||||
@if (selectedProfile() === 'pro') {
|
||||
<div class="pro-outputter-standalone-card">
|
||||
|
||||
@@ -4065,12 +4065,13 @@ $text-light: #94a3b8;
|
||||
border-radius: 18px;
|
||||
gap: 3px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.88);
|
||||
width: 172px;
|
||||
width: max-content;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
.model-toggle-btn {
|
||||
flex: 1 1 0;
|
||||
width: 50%;
|
||||
min-width: 78px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 15px;
|
||||
@@ -5168,3 +5169,19 @@ $text-light: #94a3b8;
|
||||
}
|
||||
|
||||
.composer-card > app-task-plugins { margin-bottom: 8px; }
|
||||
|
||||
.exploration-settings {
|
||||
margin:18px 0 24px; padding:18px 20px; background:#fff; border:1px solid #e2e8f0; border-radius:20px; font-size:13px; color:#475569;
|
||||
p {line-height:1.7;margin:4px 0 0}
|
||||
.exploration-intro {display:flex;gap:12px;align-items:flex-start; strong {color:#1e293b;font-size:14px} > .material-symbols-outlined {color:#6366f1;background:#eef2ff;border-radius:12px;padding:8px;font-size:22px}}
|
||||
button,input {font:inherit;box-sizing:border-box} button {cursor:pointer;transition:background .15s} button:disabled {opacity:.5;cursor:default}
|
||||
button:focus-visible,input:focus-visible {outline:2px solid #818cf8;outline-offset:3px}
|
||||
.exploration-settings-toggle {display:flex;align-items:center;gap:8px;width:100%;border:0;background:#f8fafc;color:#475569;border-radius:12px;padding:10px 12px;margin-top:14px;text-align:left; .material-symbols-outlined {font-size:18px} .settings-toggle-label {margin-left:auto;font-size:12px;color:#64748b} &:hover {background:#eef2ff}}
|
||||
.exploration-settings-body {padding-top:18px}
|
||||
.model-label {display:flex;align-items:center;gap:16px;font-weight:600;input {flex:1;min-width:0;border:1px solid #dbe3ec;border-radius:10px;padding:10px 12px;color:#1e293b;background:#fff}}
|
||||
.exploration-fields {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}
|
||||
.settings-note {font-size:12px;color:#64748b}.settings-error {color:#b91c1c}
|
||||
.settings-footer {display:flex;align-items:center;gap:12px;margin-top:14px;span {font-size:12px;color:#047857}}
|
||||
.save-settings {border:0;background:#4f46e5;color:#fff;border-radius:10px;padding:9px 16px;font-weight:600;&:hover:enabled {background:#4338ca}}
|
||||
@media(max-width:600px) {.exploration-fields {grid-template-columns:1fr}.settings-toggle-label {display:none}.exploration-settings-toggle {flex-wrap:wrap}}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
|
||||
import { signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { HomeComponent } from './home.component';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { ExplorationService } from '../../services/exploration.service';
|
||||
import { SystemService } from '../../services/system.service';
|
||||
import { TaskRecommendationService } from '../../core/services/task-recommendation.service';
|
||||
|
||||
describe('Homepage exploration entry',()=>{
|
||||
let api:any,agent:any,router:any;
|
||||
const config={model:'gpt-6.1-sol',reasoning_effort:'high',timeout_seconds:1800,demonstration_timeout_seconds:900,demonstration_warning_seconds:60};
|
||||
beforeEach(()=>{
|
||||
api={settings:jasmine.createSpy().and.resolveTo(config),create:jasmine.createSpy().and.resolveTo({id:'conversation'}),act:jasmine.createSpy().and.resolveTo({run:{id:'run'}})};
|
||||
agent={runTask:jasmine.createSpy(),selectSession:jasmine.createSpy(),fetchSessions:jasmine.createSpy(),fetchStatus:jasmine.createSpy()};router={navigate:jasmine.createSpy().and.resolveTo(true)};
|
||||
TestBed.configureTestingModule({providers:[{provide:AgentService,useValue:agent},{provide:ExplorationService,useValue:api},{provide:SystemService,useValue:{isReady:()=>true}},{provide:TaskRecommendationService,useValue:{allTasks:signal([])}},{provide:Router,useValue:router}]});
|
||||
});
|
||||
it('creates and starts exploration using independent settings and opens its run',fakeAsync(()=>{
|
||||
const home=TestBed.runInInjectionContext(()=>new HomeComponent());home.setProfile('exploration');flushMicrotasks();home.taskGoal.set('探索流程');home.runTask();flushMicrotasks();
|
||||
expect(api.create).toHaveBeenCalledWith('探索流程',config,{},jasmine.any(String));expect(api.act).toHaveBeenCalledWith('conversation','start',{},jasmine.any(String));
|
||||
expect(agent.runTask).not.toHaveBeenCalled();expect(agent.selectSession).toHaveBeenCalledWith('run',true);expect(router.navigate).toHaveBeenCalledWith(['/workspace']);expect(home.isSubmitting()).toBeFalse();
|
||||
localStorage.removeItem('artemis.taskProfile');
|
||||
}));
|
||||
it('reuses create and start IDs after a lost admission response',fakeAsync(()=>{
|
||||
const home=TestBed.runInInjectionContext(()=>new HomeComponent());home.selectedProfile.set('exploration');home.explorationConfig.set(config);home.taskGoal.set('探索流程');api.act.and.rejectWith(new Error('offline'));home.runTask();flushMicrotasks();
|
||||
const createId=api.create.calls.mostRecent().args[3],startId=api.act.calls.mostRecent().args[3];expect(home.errorMessage()).toContain('失败');
|
||||
api.act.and.resolveTo({run:{id:'run'}});home.runTask();flushMicrotasks();expect(api.create.calls.mostRecent().args[3]).toBe(createId);expect(api.act.calls.mostRecent().args[3]).toBe(startId);
|
||||
}));
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CapabilitySettingsComponent } from '../../components/model-settings/capability-settings.component';
|
||||
import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from '../../components/setting-slider/setting-slider.component';
|
||||
import { ModelSettingsComponent } from '../../components/model-settings/model-settings.component';
|
||||
/**
|
||||
* Copyright 2026 Google LLC
|
||||
@@ -20,6 +21,7 @@ import { Component, signal, computed, inject, OnInit, OnDestroy, ChangeDetection
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TaskPluginsComponent, TaskPluginOptions } from '../../components/task-plugins/task-plugins.component';
|
||||
import { Router } from '@angular/router';
|
||||
import { ExplorationService, ExplorationConfig } from '../../services/exploration.service';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { SystemService, formatAdbRestartFeedback } from '../../services/system.service';
|
||||
import {
|
||||
@@ -106,7 +108,7 @@ type AdbGuideTab = 'emulator' | 'usb' | 'wifi' | 'remote';
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
standalone: true,
|
||||
imports: [FormsModule, ModelSettingsComponent, CapabilitySettingsComponent, TaskPluginsComponent],
|
||||
imports: [FormsModule, ModelSettingsComponent, CapabilitySettingsComponent, TaskPluginsComponent, SettingSliderComponent],
|
||||
templateUrl: './home.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrl: './home.component.scss'
|
||||
@@ -168,7 +170,7 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
// Task execution parameters
|
||||
public selectedProfile = signal<'flash' | 'pro'>('flash');
|
||||
public selectedProfile = signal<'flash' | 'pro' | 'exploration'>('flash');
|
||||
public taskGoal = signal<string>('');
|
||||
public isSubmitting = signal<boolean>(false);
|
||||
public errorMessage = signal<string | null>(null);
|
||||
@@ -477,6 +479,8 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
const savedProfile = localStorage.getItem('artemis.taskProfile');
|
||||
if (savedProfile === 'exploration' || savedProfile === 'flash' || savedProfile === 'pro') this.setProfile(savedProfile);
|
||||
|
||||
this.loadProTuningDefaults();
|
||||
// Initial fetch of system readiness & model configuration
|
||||
@@ -751,8 +755,10 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
public setProfile(profile: 'flash' | 'pro'): void {
|
||||
public setProfile(profile: 'flash' | 'pro' | 'exploration'): void {
|
||||
this.selectedProfile.set(profile);
|
||||
localStorage.setItem('artemis.taskProfile', profile);
|
||||
if (profile === 'exploration' && !this.explorationConfig()) void this.loadExplorationSettings();
|
||||
}
|
||||
|
||||
public setCategory(cat: SuggestionCategory): void {
|
||||
@@ -786,7 +792,58 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
this.activeTab.set('launcher');
|
||||
}
|
||||
|
||||
exploration = inject(ExplorationService);
|
||||
readonly reasoningValues = REASONING_VALUES;
|
||||
readonly reasoningLabels = REASONING_LABELS;
|
||||
readonly executionTimes = [30,60,300,600,900,1800,3600,7200,14400,28800,86400];
|
||||
readonly executionTimeLabels = ['30 秒','1 分钟','5 分钟','10 分钟','15 分钟','30 分钟','1 小时','2 小时','4 小时','8 小时','24 小时'];
|
||||
readonly demoTimes = this.executionTimes.slice(1);
|
||||
readonly demoTimeLabels = this.executionTimeLabels.slice(1);
|
||||
readonly warningTimes = [10,30,60,120,300,600];
|
||||
readonly warningLabels = ['10 秒','30 秒','1 分钟','2 分钟','5 分钟','10 分钟'];
|
||||
explorationSettingsOpen = signal(false);
|
||||
explorationConfigValid() {
|
||||
const c = this.explorationConfig();
|
||||
return !!c?.model.trim() && c.demonstration_warning_seconds < c.demonstration_timeout_seconds;
|
||||
}
|
||||
explorationConfig = signal<ExplorationConfig | null>(null);
|
||||
explorationSettingsBusy = signal(false);
|
||||
explorationNotice = signal('');
|
||||
private explorationRequests = new Map<string, {create: string; start: string}>();
|
||||
async loadExplorationSettings() {
|
||||
this.explorationSettingsBusy.set(true);
|
||||
try { this.explorationConfig.set(await this.exploration.settings()); }
|
||||
catch { this.errorMessage.set('探索配置读取失败,请重试。'); }
|
||||
finally { this.explorationSettingsBusy.set(false); }
|
||||
}
|
||||
async saveExplorationSettings() {
|
||||
const config = this.explorationConfig(); if (!config || this.explorationSettingsBusy() || !this.explorationConfigValid()) return;
|
||||
this.explorationSettingsBusy.set(true); this.explorationNotice.set('');
|
||||
try { this.explorationConfig.set(await this.exploration.saveSettings(config)); this.explorationNotice.set('已记住探索配置'); }
|
||||
catch(e) { this.errorMessage.set((e as any)?.error?.detail || '配置保存失败'); }
|
||||
finally { this.explorationSettingsBusy.set(false); }
|
||||
}
|
||||
private async startExploration(goal: string) {
|
||||
const config = this.explorationConfig();
|
||||
if (!config) { this.errorMessage.set('请先读取探索配置。'); this.isSubmitting.set(false); return; }
|
||||
if (!this.explorationConfigValid()) { this.errorMessage.set('示范提前提醒须小于人工示范时限。'); this.isSubmitting.set(false); return; }
|
||||
const taskConfig = {...this.taskPlugins()};
|
||||
const key = JSON.stringify([goal, config, taskConfig]);
|
||||
const ids = this.explorationRequests.get(key) || {create: crypto.randomUUID(), start: crypto.randomUUID()};
|
||||
this.explorationRequests.set(key, ids);
|
||||
try {
|
||||
const conversation = await this.exploration.create(goal, config, taskConfig, ids.create);
|
||||
const result = await this.exploration.act<{run: {id: string}}>(conversation.id, 'start', {}, ids.start);
|
||||
this.explorationRequests.delete(key);
|
||||
this.agentService.selectSession(result.run.id, true);
|
||||
this.agentService.fetchSessions(); this.agentService.fetchStatus();
|
||||
await this.router.navigate(['/workspace']);
|
||||
} catch(e) { this.errorMessage.set((e as any)?.error?.detail || '探索启动失败,可重试。'); }
|
||||
finally { this.isSubmitting.set(false); }
|
||||
}
|
||||
|
||||
public runTask(): void {
|
||||
if (this.isSubmitting()) return;
|
||||
const goal = this.taskGoal().trim();
|
||||
if (!goal) {
|
||||
this.errorMessage.set("请先输入任务目标。");
|
||||
@@ -802,10 +859,12 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
this.isSubmitting.set(true);
|
||||
this.errorMessage.set(null);
|
||||
|
||||
const profile = this.selectedProfile();
|
||||
if (profile === 'exploration') { void this.startExploration(goal); return; }
|
||||
this.agentService
|
||||
.runTask(
|
||||
goal,
|
||||
this.selectedProfile(),
|
||||
profile,
|
||||
this.selectedProfile() === 'pro' && this.expectedOutput().trim()
|
||||
? this.expectedOutput().trim()
|
||||
: undefined,
|
||||
|
||||
@@ -1,238 +1,32 @@
|
||||
<!--
|
||||
Copyright 2026 Google LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<div class="workspace-container" [class.is-resizing]="isDragging()">
|
||||
<!-- Left Main Panel: Raw Agent Log Stream -->
|
||||
<main class="left-panel glass-panel">
|
||||
<app-agent-stream>
|
||||
@if (guidanceStatus(); as status) {
|
||||
<div task-guidance class="task-guidance-control">
|
||||
<button type="button" class="guidance-trigger" (click)="toggleGuidance()"
|
||||
[attr.aria-expanded]="guidanceOpenId() === agentService.currentSessionId()"
|
||||
aria-controls="workspace-guidance-panel" aria-label="指导当前任务" title="暂停或指导当前任务">
|
||||
<span class="material-symbols-outlined">edit_note</span>
|
||||
指导任务
|
||||
</button>
|
||||
@if (guidanceOpenId() === agentService.currentSessionId()) {
|
||||
<section id="workspace-guidance-panel" class="task-guidance-panel" aria-label="当前任务指导">
|
||||
<div class="guidance-heading">
|
||||
<strong>指导当前任务 · {{ status === 'running' ? '执行中' : status === 'resuming' ? '等待设备恢复' : status === 'paused' ? '模型调用暂停' : '等待指导' }}</strong>
|
||||
<span [title]="agentService.currentSession()?.initial_goal || ''">{{ agentService.currentSession()?.initial_goal || agentService.currentSessionId() }}</span>
|
||||
</div>
|
||||
<label for="workspace-task-guidance">补充信息或调整方向</label>
|
||||
<textarea id="workspace-task-guidance" [(ngModel)]="taskGuidance" rows="2" maxlength="10000"
|
||||
[disabled]="guidanceBusy()" placeholder="输入指导;将中断当前模型思考并立即重新规划"></textarea>
|
||||
<div class="guidance-actions">
|
||||
<span>{{ status === 'paused' ? '提交指导后请在执行日志中恢复模型调用' : '下方输入框用于开始新任务' }}</span>
|
||||
@if (status === 'running') {
|
||||
<button type="button" (click)="pauseCurrentTask()"
|
||||
[disabled]="guidanceBusy() || pauseRequestId() === agentService.currentSessionId()">
|
||||
{{ pauseRequestId() === agentService.currentSessionId() ? '等待暂停…' : '暂停当前任务' }}
|
||||
</button>
|
||||
}
|
||||
@if (status === 'task_paused') {
|
||||
<button type="button" (click)="continueCurrentTask()" [disabled]="guidanceBusy()">直接继续</button>
|
||||
}
|
||||
<button type="button" class="guidance-submit" (click)="submitGuidance()"
|
||||
[disabled]="guidanceBusy() || !taskGuidance.trim()">{{ status === 'task_paused' ? '提交并恢复' : '提交指导' }}</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</app-agent-stream>
|
||||
|
||||
<!-- Bottom Floating Command Bar Dock -->
|
||||
<div class="workspace-floating-bar-wrapper" [class.is-expanded]="isBarExpanded()" [class.is-dormant]="!isBarExpanded()"
|
||||
[class.is-focused]="isInputFocused()" [class.has-text]="!!taskInput.trim()" [class.is-running]="isTaskRunning()">
|
||||
<!-- Floating Error / Notification Toast -->
|
||||
@if (errorMessage()) {
|
||||
<div class="floating-dock-toast error">
|
||||
<span class="material-symbols-outlined toast-icon">error</span>
|
||||
<span class="toast-text">{{ errorMessage() }}</span>
|
||||
<button type="button" class="btn-toast-close" (click)="errorMessage.set(null)" aria-label="关闭错误消息">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Dock Row (Contains the Main Card and the Separate Stop Button when running) -->
|
||||
<div class="floating-dock-row">
|
||||
<!-- Floating Dock Main Card -->
|
||||
<div class="floating-dock-card" (click)="onCardClick($event)"
|
||||
(mouseenter)="isHoveringCard.set(true)" (mouseleave)="isHoveringCard.set(false)">
|
||||
<!-- Ambient Liquid Wave / Aurora Background Layer (Active in both Dormant & Expanded) -->
|
||||
<div class="dock-wave-container" [class.is-pro]="selectedProfile() === 'pro'">
|
||||
<div class="wave-glow-ambient"></div>
|
||||
<div class="wave-tracks-wrapper">
|
||||
<!-- Back Fluid Wave -->
|
||||
<svg class="liquid-wave wave-back" viewBox="0 0 2400 80" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="waveGradBack" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" class="wave-stop-1" />
|
||||
<stop offset="25%" class="wave-stop-2" />
|
||||
<stop offset="50%" class="wave-stop-3" />
|
||||
<stop offset="75%" class="wave-stop-4" />
|
||||
<stop offset="100%" class="wave-stop-1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M 0,25 Q 300,55 600,20 T 1200,25 Q 1500,55 1800,20 T 2400,25 L 2400,80 L 0,80 Z"
|
||||
fill="url(#waveGradBack)"></path>
|
||||
</svg>
|
||||
|
||||
<!-- Mid Fluid Wave -->
|
||||
<svg class="liquid-wave wave-mid" viewBox="0 0 2400 80" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="waveGradMid" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" class="wave-stop-3" />
|
||||
<stop offset="35%" class="wave-stop-1" />
|
||||
<stop offset="70%" class="wave-stop-4" />
|
||||
<stop offset="100%" class="wave-stop-3" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M 0,35 Q 300,5 600,45 T 1200,35 Q 1500,5 1800,45 T 2400,35 L 2400,80 L 0,80 Z"
|
||||
fill="url(#waveGradMid)"></path>
|
||||
</svg>
|
||||
|
||||
<!-- Front Fluid Wave -->
|
||||
<svg class="liquid-wave wave-front" viewBox="0 0 2400 80" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="waveGradFront" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" class="wave-stop-2" />
|
||||
<stop offset="30%" class="wave-stop-4" />
|
||||
<stop offset="65%" class="wave-stop-1" />
|
||||
<stop offset="100%" class="wave-stop-2" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M 0,18 Q 300,50 600,15 T 1200,18 Q 1500,50 1800,15 T 2400,18 L 2400,80 L 0,80 Z"
|
||||
fill="url(#waveGradFront)"></path>
|
||||
</svg>
|
||||
</div>
|
||||
@if (explorationId(); as id) {
|
||||
<app-exploration-panel [sessionId]="id" />
|
||||
} @else {
|
||||
<app-agent-stream>
|
||||
@if(agentService.currentSession()?.exploration_kind === 'validation') {
|
||||
<div task-summary class="origin-note">用例 v{{agentService.currentSession()?.case_version}} 的独立复跑
|
||||
<button type="button" (click)="openOrigin()">查看来源探索</button>
|
||||
</div>
|
||||
|
||||
<!-- Dormant / Capsule Peek View (Visible when mouse is away and input idle) -->
|
||||
<div class="capsule-peek-view">
|
||||
<span class="capsule-placeholder">开始新任务…</span>
|
||||
</div>
|
||||
|
||||
<!-- Expanded Full View Content (Visible on hover, focus, or text) -->
|
||||
<div class="expanded-card-content">
|
||||
<div class="card-main-input-row">
|
||||
<!-- Textarea Container with Dynamic English Placeholder -->
|
||||
<div class="input-content-area">
|
||||
<textarea #dockInput [(ngModel)]="taskInput"
|
||||
(input)="onTextareaInput($event)"
|
||||
(keydown)="onKeyDown($event)"
|
||||
(focus)="onInputFocus()"
|
||||
(blur)="onInputBlur()"
|
||||
rows="1"
|
||||
[disabled]="isSubmitting()"
|
||||
[placeholder]="selectedProfile() === 'pro'
|
||||
? 'Pro: plans first, checks results and recovers from blocked steps. For long or complex tasks'
|
||||
: 'Flash: fast single-look loop for everyday app tasks'"
|
||||
class="dock-textarea"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Clear Button (visible when text is entered) -->
|
||||
@if (taskInput.trim() && !isSubmitting()) {
|
||||
<button type="button" class="dock-circle-btn btn-clear"
|
||||
(click)="clearInput($event)" title="清空输入" aria-label="清空输入">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Stop Button (Visible when currently viewed task is active) -->
|
||||
@if (isTaskRunning()) {
|
||||
<button type="button" class="dock-circle-btn btn-stop" [disabled]="isSubmitting()"
|
||||
(click)="stopTask($event)"
|
||||
[title]="stopButtonTitle()"
|
||||
[attr.aria-label]="stopButtonTitle()"
|
||||
>
|
||||
<span class="material-symbols-outlined">stop</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Send Button -->
|
||||
<button type="button" class="dock-circle-btn btn-send"
|
||||
[class.has-content]="!!taskInput.trim()"
|
||||
[disabled]="!taskInput.trim() || isSubmitting()"
|
||||
(click)="submitTask()"
|
||||
[title]="isSubmitting() ? 'Starting task...' : 'Start task (Enter)'"
|
||||
aria-label="开始任务"
|
||||
>
|
||||
@if (isSubmitting()) {
|
||||
<span class="material-symbols-outlined spinning-icon">sync</span>
|
||||
}
|
||||
@if (!isSubmitting()) {
|
||||
<span class="material-symbols-outlined">arrow_upward</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Segmented Controls Row -->
|
||||
<app-task-plugins class="dock-plugin-options" (changed)="taskPlugins = $event" />
|
||||
<div class="card-footer-row">
|
||||
<div class="footer-left-group">
|
||||
<!-- Segmented Architecture Toggle (Flash / Pro) -->
|
||||
<div class="model-profile-toggle" role="group" aria-label="选择执行模式">
|
||||
<button type="button" class="profile-toggle-pill" [class.active]="selectedProfile() === 'flash'"
|
||||
(mousedown)="$event.preventDefault()" (click)="setProfile('flash', $event)"
|
||||
title="Flash:快速执行日常应用任务(每步 3–5 秒)">
|
||||
<span class="material-symbols-outlined pill-icon icon-flash">bolt</span>
|
||||
<span class="pill-label">Flash</span>
|
||||
</button>
|
||||
<button type="button" class="profile-toggle-pill" [class.active]="selectedProfile() === 'pro'"
|
||||
(mousedown)="$event.preventDefault()" (click)="setProfile('pro', $event)"
|
||||
title="Pro:先规划、检查结果并恢复受阻步骤,适合长流程或复杂任务(每步 15–40 秒)">
|
||||
<span class="material-symbols-outlined pill-icon icon-pro">psychology</span>
|
||||
<span class="pill-label">Pro</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Separate Standalone Stop Button Beside the Capsule (Visible when currently viewed task is active) -->
|
||||
@if (isTaskRunning()) {
|
||||
<button type="button" class="standalone-stop-btn"
|
||||
[disabled]="isSubmitting()"
|
||||
(click)="stopTask($event)"
|
||||
[title]="stopButtonTitle()"
|
||||
[attr.aria-label]="stopButtonTitle()"
|
||||
>
|
||||
<span class="material-symbols-outlined">stop</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</app-agent-stream>
|
||||
<section class="task-composer" aria-label="当前任务指导">
|
||||
@if(errorMessage()){<p role="alert" class="feedback error">{{errorMessage()}}</p>}
|
||||
@if(notice()){<p role="status" class="feedback">{{notice()}}</p>}
|
||||
<div class="composer-heading"><strong>指导当前任务</strong><span>{{agentService.currentSession()?.display_name || agentService.currentSession()?.initial_goal || '请从右侧选择任务'}}</span></div>
|
||||
<textarea #dockInput aria-label="指导当前任务" [(ngModel)]="taskGuidance" (keydown)="onKeyDown($event)" rows="2" maxlength="10000"
|
||||
[disabled]="!guidanceStatus() || guidanceBusy()" [placeholder]="guidanceStatus() ? '补充信息或纠正操作方向,Enter 发送,Shift+Enter 换行' : '任务已结束或尚未执行,当前不可发送指导' "></textarea>
|
||||
<div class="composer-actions">
|
||||
<small>{{guidanceStatus() === 'paused' ? '模型调用暂停:提交指导后,请在执行记录中恢复模型调用。' : guidanceStatus() ? '指导只发送给当前任务,不会创建新任务。' : '新任务请从“新任务 / 首页”创建。'}}</small>
|
||||
@if(guidanceStatus() === 'running'){<button (click)="pauseCurrentTask()" [disabled]="guidanceBusy()">暂停</button>}
|
||||
@if(guidanceStatus() === 'task_paused'){<button (click)="continueCurrentTask()" [disabled]="guidanceBusy()">直接继续</button>}
|
||||
@if(guidanceStatus()){<button (click)="stopTask()" [disabled]="guidanceBusy()">停止</button>}
|
||||
<button class="primary" (click)="submitGuidance()" [disabled]="!guidanceStatus() || guidanceBusy() || !taskGuidance.trim()">{{guidanceStatus() === 'task_paused' ? '提交并恢复' : '发送指导'}}</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</main>
|
||||
|
||||
<!-- Split Pane Drag Resizer -->
|
||||
<div class="resizer" (mousedown)="onDragStart($event)">
|
||||
<div class="resizer-handle"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right Sidebar Panel: Chat Interface Mock -->
|
||||
<aside class="right-panel" [style.width.px]="rightPanelWidth()">
|
||||
<app-chat-interface></app-chat-interface>
|
||||
</aside>
|
||||
|
||||
<!-- Floating Video Replay Player Modal / Window -->
|
||||
<app-floating-video-player></app-floating-video-player>
|
||||
<div class="resizer" (mousedown)="onDragStart($event)"><div class="resizer-handle"></div></div>
|
||||
<aside class="right-panel" [style.width.px]="rightPanelWidth()"><app-chat-interface /></aside>
|
||||
<app-floating-video-player />
|
||||
</div>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,118 +1,56 @@
|
||||
import { Component, computed, signal } from '@angular/core';
|
||||
import { Component, computed, signal, input } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { of, Subject } from 'rxjs';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { ExplorationService } from '../../services/exploration.service';
|
||||
import { AgentStreamComponent } from '../../components/agent-stream/agent-stream.component';
|
||||
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
|
||||
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
|
||||
import { ExplorationPanelComponent } from '../../components/exploration-panel/exploration-panel.component';
|
||||
import { WorkspaceComponent } from './workspace.component';
|
||||
|
||||
@Component({ selector: 'app-agent-stream', standalone: true,
|
||||
template: '<div class="stream-floating-toolbar"><ng-content select="[task-guidance]"></ng-content></div>' })
|
||||
class StreamStub {}
|
||||
@Component({ selector: 'app-chat-interface', standalone: true, template: '' })
|
||||
class ChatStub {}
|
||||
@Component({ selector: 'app-floating-video-player', standalone: true, template: '' })
|
||||
class VideoStub {}
|
||||
@Component({selector:'app-agent-stream',standalone:true,template:'<ng-content></ng-content>'}) class StreamStub {}
|
||||
@Component({selector:'app-chat-interface',standalone:true,template:'任务队列'}) class ChatStub {}
|
||||
@Component({selector:'app-floating-video-player',standalone:true,template:''}) class VideoStub {}
|
||||
@Component({selector:'app-exploration-panel',standalone:true,template:'探索交流 {{sessionId()}}'}) class ExploreStub {sessionId=input('');}
|
||||
|
||||
describe('Workspace task guidance', () => {
|
||||
const currentId = signal<string | null>('running-task');
|
||||
const session = signal({ session_id: 'running-task', status: 'running', initial_goal: '观察设置' });
|
||||
const agent = {
|
||||
currentSessionId: currentId,
|
||||
currentSession: computed(() => currentId() === session().session_id ? session() : null),
|
||||
runningSessionId: signal<string | null>('running-task'),
|
||||
agentStatus: signal('running'),
|
||||
isCurrentSessionRunning: computed(() => ['running', 'paused'].includes(session().status)),
|
||||
fetchStatus: jasmine.createSpy('fetchStatus'),
|
||||
fetchSessions: jasmine.createSpy('fetchSessions'),
|
||||
fetchGuidance: jasmine.createSpy('fetchGuidance')
|
||||
};
|
||||
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['post']);
|
||||
|
||||
beforeEach(async () => {
|
||||
currentId.set('running-task');
|
||||
session.set({ session_id: 'running-task', status: 'running', initial_goal: '观察设置' });
|
||||
agent.runningSessionId.set('running-task');
|
||||
http.post.and.returnValue(of({}));
|
||||
http.post.calls.reset();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WorkspaceComponent],
|
||||
providers: [
|
||||
{ provide: AgentService, useValue: agent },
|
||||
{ provide: HttpClient, useValue: http }
|
||||
]
|
||||
}).overrideComponent(WorkspaceComponent, {
|
||||
remove: { imports: [AgentStreamComponent, ChatInterfaceComponent, FloatingVideoPlayerComponent] },
|
||||
add: { imports: [StreamStub, ChatStub, VideoStub] }
|
||||
}).compileComponents();
|
||||
describe('Workspace current-task composer', () => {
|
||||
const selected=signal<any>({});
|
||||
const agent={currentSession: selected,currentSessionId:computed(()=>selected().session_id),runningSessionId:signal('run'),agentStatus:signal('running'),fetchStatus:jasmine.createSpy(),fetchSessions:jasmine.createSpy(),fetchGuidance:jasmine.createSpy()};
|
||||
const http=jasmine.createSpyObj('HttpClient',['post']);
|
||||
beforeEach(async()=>{
|
||||
selected.set({session_id:'run',status:'running',initial_goal:'观察设置'});
|
||||
http.post.calls.reset();http.post.and.returnValue(of({}));
|
||||
await TestBed.configureTestingModule({imports:[WorkspaceComponent],providers:[{provide:AgentService,useValue:agent},{provide:HttpClient,useValue:http},{provide:ExplorationService,useValue:{}}]})
|
||||
.overrideComponent(WorkspaceComponent,{remove:{imports:[AgentStreamComponent,ChatInterfaceComponent,FloatingVideoPlayerComponent,ExplorationPanelComponent]},add:{imports:[StreamStub,ChatStub,VideoStub,ExploreStub]}}).compileComponents();
|
||||
});
|
||||
|
||||
it('shows pause and guidance for the selected running task, then hides them on cancellation', async () => {
|
||||
const fixture = TestBed.createComponent(WorkspaceComponent);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.stream-floating-toolbar .guidance-trigger')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.workspace-floating-bar-wrapper .guidance-trigger')).toBeNull();
|
||||
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
const panel = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
|
||||
expect(panel.textContent).toContain('指导当前任务');
|
||||
expect(panel.textContent).toContain('下方输入框用于开始新任务');
|
||||
|
||||
const pause = Array.from(panel.querySelectorAll('button')).find(b => b.textContent?.includes('暂停当前任务'))!;
|
||||
pause.click();
|
||||
await fixture.whenStable();
|
||||
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/pause', {});
|
||||
|
||||
fixture.detectChanges();
|
||||
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
const reopened = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
|
||||
const input = reopened.querySelector('textarea')!;
|
||||
input.value = '先确认设置首页';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
const submit = Array.from(reopened.querySelectorAll('button')).find(b => b.textContent?.includes('提交指导'))!;
|
||||
submit.click();
|
||||
await fixture.whenStable();
|
||||
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/guide', { instruction: '先确认设置首页' });
|
||||
expect(agent.fetchGuidance).toHaveBeenCalledWith('running-task');
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).not.toContain('指导已提交');
|
||||
|
||||
session.set({ session_id: 'running-task', status: 'cancelled', initial_goal: '观察设置' });
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.guidance-trigger')).toBeNull();
|
||||
it('guides the selected ordinary task without submitting a new task',async()=>{
|
||||
const f=TestBed.createComponent(WorkspaceComponent);f.detectChanges();
|
||||
const input=f.nativeElement.querySelector('textarea');input.value='先观察';input.dispatchEvent(new Event('input'));f.detectChanges();
|
||||
await f.componentInstance.submitGuidance();
|
||||
expect(http.post).toHaveBeenCalledWith('/api/tasks/run/guide',jasmine.objectContaining({instruction:'先观察',request_id:jasmine.any(String)}));
|
||||
expect(f.componentInstance.taskGuidance).toBe('');
|
||||
selected.set({...selected(),status:'completed'});f.detectChanges();await f.whenStable();f.detectChanges();expect(input.disabled).toBeTrue();
|
||||
expect(f.nativeElement.textContent).not.toContain('开始新任务');f.destroy();
|
||||
});
|
||||
|
||||
it('continues a paused task without creating a new task', async () => {
|
||||
session.set({ session_id: 'running-task', status: 'task_paused', initial_goal: '观察设置' });
|
||||
const fixture = TestBed.createComponent(WorkspaceComponent);
|
||||
fixture.detectChanges();
|
||||
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
const button = Array.from<HTMLElement>(fixture.nativeElement.querySelectorAll('.task-guidance-panel button'))
|
||||
.find(b => b.textContent?.includes('直接继续'))!;
|
||||
button.click();
|
||||
await fixture.whenStable();
|
||||
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/continue', {});
|
||||
it('keeps a Flash validation ordinary and opens exploration only for exploration runs',()=>{
|
||||
const f=TestBed.createComponent(WorkspaceComponent);
|
||||
selected.set({...selected(),exploration_id:'conversation',exploration_kind:'validation',case_version:2});f.detectChanges();
|
||||
expect(f.nativeElement.querySelector('app-exploration-panel')).toBeNull();expect(f.nativeElement.textContent).toContain('查看来源探索');
|
||||
selected.set({...selected(),exploration_kind:'exploration'});f.detectChanges();
|
||||
expect(f.nativeElement.querySelector('app-exploration-panel').textContent).toContain('conversation');
|
||||
expect(f.nativeElement.querySelector('.task-composer')).toBeNull();f.destroy();
|
||||
});
|
||||
|
||||
it('does not offer task resume for a paused model request', () => {
|
||||
session.set({ session_id: 'running-task', status: 'paused', initial_goal: '观察设置' });
|
||||
const fixture = TestBed.createComponent(WorkspaceComponent);
|
||||
fixture.detectChanges();
|
||||
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
const panel = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
|
||||
expect(panel.textContent).toContain('模型调用暂停');
|
||||
expect(panel.textContent).toContain('请在执行日志中恢复模型调用');
|
||||
expect(panel.textContent).not.toContain('直接继续');
|
||||
expect(panel.textContent).not.toContain('提交并恢复');
|
||||
it('does not erase another task draft or navigate after a delayed send',async()=>{
|
||||
const reply=new Subject<any>();http.post.and.returnValue(reply);
|
||||
const f=TestBed.createComponent(WorkspaceComponent);f.detectChanges();const c=f.componentInstance;c.taskGuidance='A';const pending=c.submitGuidance();
|
||||
selected.set({session_id:'other',status:'running'});f.detectChanges();c.taskGuidance='B';reply.next({});reply.complete();await pending;
|
||||
expect(c.taskGuidance).toBe('B');expect(c.notice()).toBe('');f.destroy();
|
||||
});
|
||||
it('continues a task pause while leaving model request recovery separate',async()=>{
|
||||
selected.set({...selected(),status:'task_paused'});const f=TestBed.createComponent(WorkspaceComponent);f.detectChanges();await f.componentInstance.continueCurrentTask();
|
||||
expect(http.post).toHaveBeenCalledWith('/api/tasks/run/continue',{});
|
||||
selected.set({...selected(),status:'paused'});f.detectChanges();expect(f.nativeElement.textContent).toContain('模型调用暂停');expect(f.nativeElement.textContent).not.toContain('直接继续');f.destroy();
|
||||
});
|
||||
});
|
||||
@@ -1,372 +1,96 @@
|
||||
/**
|
||||
* Copyright 2026 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Component, ChangeDetectionStrategy, NgZone, DestroyRef, inject, computed, signal, ViewChild, ElementRef, OnInit } from '@angular/core';
|
||||
|
||||
import { Component, ChangeDetectionStrategy, NgZone, DestroyRef, inject, computed, signal, ViewChild, ElementRef, OnInit, effect } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { AgentStreamComponent } from '../../components/agent-stream/agent-stream.component';
|
||||
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
|
||||
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
|
||||
import { ExplorationPanelComponent } from '../../components/exploration-panel/exploration-panel.component';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { TaskPluginsComponent, TaskPluginOptions } from '../../components/task-plugins/task-plugins.component';
|
||||
import { ExplorationService } from '../../services/exploration.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace',
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
TaskPluginsComponent,
|
||||
AgentStreamComponent,
|
||||
ChatInterfaceComponent,
|
||||
FloatingVideoPlayerComponent
|
||||
],
|
||||
templateUrl: './workspace.component.html',
|
||||
styleUrl: './workspace.component.scss',
|
||||
selector: 'app-workspace', standalone: true,
|
||||
imports: [FormsModule, AgentStreamComponent, ChatInterfaceComponent, FloatingVideoPlayerComponent, ExplorationPanelComponent],
|
||||
templateUrl: './workspace.component.html', styleUrl: './workspace.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class WorkspaceComponent implements OnInit {
|
||||
taskPlugins: TaskPluginOptions = {};
|
||||
public agentService = inject(AgentService);
|
||||
agentService = inject(AgentService);
|
||||
exploration = inject(ExplorationService);
|
||||
private http = inject(HttpClient);
|
||||
private zone = inject(NgZone);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Default right panel width to 1/3 of the screen (or 450px as fallback)
|
||||
public rightPanelWidth = signal<number>(
|
||||
typeof window !== 'undefined' ? Math.round(window.innerWidth / 3) : 450
|
||||
);
|
||||
public isDragging = signal<boolean>(false);
|
||||
rightPanelWidth = signal(Math.round(window.innerWidth / 3));
|
||||
isDragging = signal(false);
|
||||
private dragWidthRafId: number | null = null;
|
||||
private pendingDragWidth = 0;
|
||||
|
||||
// Floating Command Bar State. taskInput is backed by a signal so computed
|
||||
// expressions (isBarExpanded) genuinely track it under OnPush.
|
||||
private taskInputSignal = signal<string>('');
|
||||
public get taskInput(): string { return this.taskInputSignal(); }
|
||||
public set taskInput(value: string) { this.taskInputSignal.set(value); }
|
||||
public isSubmitting = signal<boolean>(false);
|
||||
public errorMessage = signal<string | null>(null);
|
||||
public selectedProfile = signal<'flash' | 'pro'>('flash');
|
||||
private guidanceDrafts = new Map<string, string>();
|
||||
public guidanceBusy = signal(false);
|
||||
public guidanceOpenId = signal<string | null>(null);
|
||||
public pauseRequestId = signal<string | null>(null);
|
||||
|
||||
public get taskGuidance(): string {
|
||||
const id = this.agentService.currentSessionId();
|
||||
return id ? this.guidanceDrafts.get(id) || '' : '';
|
||||
guidanceBusy = signal(false);
|
||||
errorMessage = signal('');
|
||||
notice = signal('');
|
||||
private requests = new Map<string, string>();
|
||||
constructor() {
|
||||
effect(() => { this.agentService.currentSessionId(); this.notice.set(''); this.errorMessage.set(''); });
|
||||
}
|
||||
|
||||
public set taskGuidance(value: string) {
|
||||
@ViewChild('dockInput') dockInputRef?: ElementRef<HTMLTextAreaElement>;
|
||||
isExploration = computed(() => this.agentService.currentSession()?.exploration_kind === 'exploration');
|
||||
explorationId = computed(() => this.isExploration() ? this.agentService.currentSession()?.exploration_id || null : null);
|
||||
get taskGuidance(): string { return this.guidanceDrafts.get(this.agentService.currentSessionId() || '') || ''; }
|
||||
set taskGuidance(value: string) { const id = this.agentService.currentSessionId(); if (id) this.guidanceDrafts.set(id, value); }
|
||||
guidanceStatus = computed(() => {
|
||||
const id = this.agentService.currentSessionId();
|
||||
if (id) this.guidanceDrafts.set(id, value);
|
||||
}
|
||||
|
||||
public guidanceStatus = computed(() => {
|
||||
const id = this.agentService.currentSessionId();
|
||||
if (!id) return null;
|
||||
const status = this.agentService.currentSession()?.status
|
||||
|| (this.agentService.runningSessionId() === id ? this.agentService.agentStatus() : '');
|
||||
if (!id || this.isExploration()) return null;
|
||||
const status = this.agentService.currentSession()?.status || (this.agentService.runningSessionId() === id ? this.agentService.agentStatus() : '');
|
||||
return ['running', 'paused', 'task_paused', 'resuming'].includes(status || '') ? status : null;
|
||||
});
|
||||
|
||||
public toggleGuidance(): void {
|
||||
const id = this.agentService.currentSessionId();
|
||||
this.guidanceOpenId.set(id && this.guidanceOpenId() !== id ? id : null);
|
||||
ngOnInit() {
|
||||
this.zone.runOutsideAngular(() => window.addEventListener('keydown', this.onGlobalKeyDown));
|
||||
this.destroyRef.onDestroy(() => { window.removeEventListener('keydown', this.onGlobalKeyDown); this.detachDragListeners(); });
|
||||
}
|
||||
|
||||
// Expand States (Signals for 0-latency reactivity)
|
||||
public isHoveringCard = signal<boolean>(false);
|
||||
public isInputFocused = signal<boolean>(false);
|
||||
|
||||
@ViewChild('dockInput') public dockInputRef?: ElementRef<HTMLTextAreaElement>;
|
||||
|
||||
ngOnInit(): void {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const saved = localStorage.getItem('artemis_selected_profile');
|
||||
if (saved === 'flash' || saved === 'pro') {
|
||||
this.selectedProfile.set(saved);
|
||||
}
|
||||
}
|
||||
|
||||
// The global ⌘K/Ctrl+K shortcut is registered outside the Angular zone so
|
||||
// ordinary typing never schedules an extra change-detection pass.
|
||||
this.zone.runOutsideAngular(() => {
|
||||
window.addEventListener('keydown', this.onGlobalKeyDown);
|
||||
});
|
||||
this.destroyRef.onDestroy(() => {
|
||||
window.removeEventListener('keydown', this.onGlobalKeyDown);
|
||||
this.detachDragListeners();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set agent architecture profile ('flash' vs 'pro')
|
||||
*/
|
||||
public setProfile(profile: 'flash' | 'pro', event?: MouseEvent): void {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
this.selectedProfile.set(profile);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('artemis_selected_profile', profile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed boolean whether the currently viewed task is actively running or paused.
|
||||
* Only displays the stop/cancel button when inspecting an active task.
|
||||
*/
|
||||
public isTaskRunning = computed(() => {
|
||||
return this.agentService.isCurrentSessionRunning();
|
||||
});
|
||||
|
||||
public async pauseCurrentTask(): Promise<void> {
|
||||
const id = this.agentService.currentSessionId();
|
||||
if (!id || this.guidanceStatus() !== 'running' || this.guidanceBusy()) return;
|
||||
this.guidanceBusy.set(true);
|
||||
this.errorMessage.set(null);
|
||||
try {
|
||||
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/pause`, {}));
|
||||
this.pauseRequestId.set(id);
|
||||
this.guidanceOpenId.set(null);
|
||||
this.agentService.fetchStatus();
|
||||
this.agentService.fetchSessions();
|
||||
} catch (error) {
|
||||
this.showGuidanceError(error);
|
||||
} finally {
|
||||
this.guidanceBusy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async submitGuidance(): Promise<void> {
|
||||
const id = this.agentService.currentSessionId();
|
||||
const instruction = this.taskGuidance.trim();
|
||||
const status = this.guidanceStatus();
|
||||
if (!id || !status || !instruction || this.guidanceBusy()) return;
|
||||
this.guidanceBusy.set(true);
|
||||
this.errorMessage.set(null);
|
||||
try {
|
||||
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/guide`, { instruction }));
|
||||
this.guidanceDrafts.delete(id);
|
||||
this.pauseRequestId.set(null);
|
||||
this.guidanceOpenId.set(null);
|
||||
this.agentService.fetchGuidance(id);
|
||||
this.agentService.fetchStatus();
|
||||
this.agentService.fetchSessions();
|
||||
} catch (error) {
|
||||
this.showGuidanceError(error);
|
||||
} finally {
|
||||
this.guidanceBusy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async continueCurrentTask(): Promise<void> {
|
||||
const id = this.agentService.currentSessionId();
|
||||
if (!id || this.guidanceStatus() !== 'task_paused' || this.guidanceBusy()) return;
|
||||
this.guidanceBusy.set(true);
|
||||
this.errorMessage.set(null);
|
||||
try {
|
||||
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/continue`, {}));
|
||||
this.pauseRequestId.set(null);
|
||||
this.guidanceOpenId.set(null);
|
||||
this.agentService.fetchStatus();
|
||||
this.agentService.fetchSessions();
|
||||
} catch (error) {
|
||||
this.showGuidanceError(error);
|
||||
} finally {
|
||||
this.guidanceBusy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private showGuidanceError(error: unknown): void {
|
||||
const response = error as { error?: { detail?: string }; message?: string };
|
||||
this.errorMessage.set(response.error?.detail || response.message || '操作失败,请刷新任务状态后重试');
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic tooltip and label indicating which task will be stopped
|
||||
*/
|
||||
public stopButtonTitle = computed(() => {
|
||||
const session = this.agentService.currentSession();
|
||||
if (session?.initial_goal) {
|
||||
const truncated = session.initial_goal.length > 45
|
||||
? session.initial_goal.substring(0, 42) + '...'
|
||||
: session.initial_goal;
|
||||
return `停止当前任务:"${truncated}"`;
|
||||
}
|
||||
const curId = this.agentService.currentSessionId();
|
||||
if (curId) {
|
||||
return `停止当前任务(${curId})`;
|
||||
}
|
||||
return "停止当前任务";
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed boolean whether the command bar should be in its expanded state:
|
||||
* - Mouse is hovering directly on the command card
|
||||
* - Input textarea is focused
|
||||
* - User has entered task text (drafting)
|
||||
*/
|
||||
public isBarExpanded = computed(() => {
|
||||
return (
|
||||
this.isHoveringCard() ||
|
||||
this.isInputFocused() ||
|
||||
this.taskInput.trim().length > 0
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Focus input handler
|
||||
*/
|
||||
public onInputFocus(): void {
|
||||
this.isInputFocused.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blur input handler
|
||||
*/
|
||||
public onInputBlur(): void {
|
||||
this.isInputFocused.set(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on the resting capsule or dock card to focus textarea
|
||||
*/
|
||||
public onCardClick(event: MouseEvent): void {
|
||||
const target = event.target as HTMLElement;
|
||||
// Don't steal focus if clicking action buttons or textarea directly
|
||||
if (target.closest('button') || target.tagName.toLowerCase() === 'textarea') {
|
||||
return;
|
||||
}
|
||||
this.focusInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus textarea programmatically
|
||||
*/
|
||||
public focusInput(): void {
|
||||
setTimeout(() => {
|
||||
this.dockInputRef?.nativeElement?.focus();
|
||||
}, 30);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear typed task input
|
||||
*/
|
||||
public clearInput(event?: MouseEvent): void {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
this.taskInput = '';
|
||||
if (this.dockInputRef?.nativeElement) {
|
||||
this.dockInputRef.nativeElement.style.height = 'auto';
|
||||
this.dockInputRef.nativeElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-resize textarea as user types multi-line tasks
|
||||
*/
|
||||
public onTextareaInput(event: Event): void {
|
||||
const textarea = event.target as HTMLTextAreaElement;
|
||||
if (!textarea) return;
|
||||
textarea.style.height = 'auto';
|
||||
const newHeight = Math.min(Math.max(textarea.scrollHeight, 24), 120);
|
||||
textarea.style.height = `${newHeight}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global keyboard shortcut (⌘K or Ctrl+K) to focus input bar from anywhere
|
||||
*/
|
||||
private onGlobalKeyDown = (event: KeyboardEvent): void => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault();
|
||||
this.focusInput();
|
||||
private onGlobalKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k' && !this.isExploration()) {
|
||||
event.preventDefault(); this.dockInputRef?.nativeElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle enter key in floating dock textarea
|
||||
*/
|
||||
public onKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
this.submitTask();
|
||||
}
|
||||
onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); void this.submitGuidance(); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a new task instruction
|
||||
*/
|
||||
public submitTask(): void {
|
||||
const goal = this.taskInput.trim();
|
||||
if (!goal || this.isSubmitting()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSubmitting.set(true);
|
||||
this.errorMessage.set(null);
|
||||
|
||||
if (this.dockInputRef?.nativeElement) {
|
||||
this.dockInputRef.nativeElement.blur();
|
||||
}
|
||||
this.isInputFocused.set(false);
|
||||
|
||||
this.agentService.runTask(goal, this.selectedProfile(), undefined, undefined, undefined, { ...this.taskPlugins }).subscribe({
|
||||
next: (res) => {
|
||||
this.taskInput = '';
|
||||
if (this.dockInputRef?.nativeElement) {
|
||||
this.dockInputRef.nativeElement.style.height = 'auto';
|
||||
}
|
||||
this.isSubmitting.set(false);
|
||||
this.agentService.fetchStatus();
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to submit task:', err);
|
||||
this.isSubmitting.set(false);
|
||||
this.errorMessage.set(err.error?.detail || "执行器忙碌,请等待当前任务结束。");
|
||||
setTimeout(() => {
|
||||
this.errorMessage.set(null);
|
||||
}, 5000);
|
||||
private async guideAction(action: string, body: object = {}) {
|
||||
const id = this.agentService.currentSessionId();
|
||||
if (!id || !this.guidanceStatus() || this.guidanceBusy()) return;
|
||||
const sentText = this.taskGuidance;
|
||||
this.guidanceBusy.set(true); this.errorMessage.set(''); this.notice.set('');
|
||||
try {
|
||||
const key = JSON.stringify([id,action,body]);
|
||||
const requestId = this.requests.get(key) || crypto.randomUUID(); this.requests.set(key,requestId);
|
||||
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/${action}`, action === 'guide' ? {...body,request_id:requestId} : body));
|
||||
this.requests.delete(key);
|
||||
if (action === 'guide') {
|
||||
if (this.guidanceDrafts.get(id) === sentText) this.guidanceDrafts.delete(id);
|
||||
this.agentService.fetchGuidance(id);
|
||||
}
|
||||
});
|
||||
if (this.agentService.currentSessionId() === id) {
|
||||
this.notice.set(action === 'guide' ? '指导已发送,是否落实请查看后续操作记录。' : action === 'pause' ? '正在等待当前动作安全结束。' : '已请求继续执行。');
|
||||
}
|
||||
this.agentService.fetchSessions(); this.agentService.fetchStatus();
|
||||
} catch (e) {
|
||||
if (this.agentService.currentSessionId() === id) { const error = e as {error?:{detail?:string};message?:string}; this.errorMessage.set(error.error?.detail || error.message || '操作失败,请重试'); }
|
||||
} finally { this.guidanceBusy.set(false); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop currently viewed task
|
||||
*/
|
||||
public stopTask(event?: MouseEvent): void {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
if (!this.isTaskRunning()) {
|
||||
return;
|
||||
}
|
||||
const targetSessionId = this.agentService.currentSessionId();
|
||||
this.isSubmitting.set(true);
|
||||
this.errorMessage.set(null);
|
||||
this.agentService.stopTask(targetSessionId, false);
|
||||
setTimeout(() => {
|
||||
this.isSubmitting.set(false);
|
||||
}, 400);
|
||||
async submitGuidance() { if (this.taskGuidance.trim()) await this.guideAction('guide', {instruction:this.taskGuidance.trim()}); }
|
||||
async pauseCurrentTask() { if (this.guidanceStatus() === 'running') await this.guideAction('pause'); }
|
||||
async continueCurrentTask() { if (this.guidanceStatus() === 'task_paused') await this.guideAction('continue'); }
|
||||
stopTask() { if (this.guidanceStatus()) this.agentService.stopTask(this.agentService.currentSessionId(), false); }
|
||||
async openOrigin() {
|
||||
const selectedId = this.agentService.currentSessionId();
|
||||
const origin = this.agentService.currentSession()?.exploration_id; if (!origin) return;
|
||||
try {
|
||||
const session = await this.exploration.get(origin);
|
||||
const run = [...session.runs].reverse().find(r => r.kind === 'exploration');
|
||||
if (run && this.agentService.currentSessionId() === selectedId) this.agentService.selectSession(run.id, true);
|
||||
} catch { this.errorMessage.set('来源探索暂时无法读取,请重试'); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse down on resizer bar to start dragging. The move/up listeners
|
||||
* are attached only for the duration of the drag and run outside the Angular
|
||||
|
||||
@@ -510,3 +510,24 @@ describe('AgentService task cancellation and active session tracking', () => {
|
||||
expect(service.activeTasks().map((at: any) => at.session_id)).toEqual(['sess-1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Exploration queue grouping',()=>{
|
||||
const service=Object.create(AgentService.prototype) as AgentService;
|
||||
it('keeps validations independent and groups only exploration executions',()=>{
|
||||
service.batchFilter=signal('__exploration__');
|
||||
const old:any={session_id:'old',exploration_id:'e',exploration_kind:'exploration',exploration_latest_run_id:'new'};
|
||||
const next:any={...old,session_id:'new',exploration_status:'paused'};
|
||||
const validation:any={...old,session_id:'validation',exploration_kind:'validation'};
|
||||
expect(service.isQueueRepresentative(old)).toBeFalse();expect(service.isQueueRepresentative(next)).toBeTrue();expect(service.isQueueRepresentative(validation)).toBeTrue();
|
||||
expect(service.matchesBatchFilter(next)).toBeTrue();expect(service.matchesBatchFilter(validation)).toBeFalse();expect(service.explorationQueueStatus(next)).toBe('paused');
|
||||
service.batchFilter.set('__single__');expect(service.matchesBatchFilter(validation)).toBeTrue();expect(service.matchesBatchFilter(next)).toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Queued exploration identity',()=>{
|
||||
it('preserves exploration mode before the session row exists',()=>{
|
||||
const service=Object.create(AgentService.prototype) as AgentService;
|
||||
const fields=service.explorationSessionFields({exploration_id:'conversation',exploration_kind:'exploration',exploration_model:'gpt-6.1-sol',exploration_latest_run_id:'new',exploration_status:'queued'});
|
||||
expect(fields.exploration_id).toBe('conversation');expect(fields.model_info?.architecture).toBe('explore');expect(fields.exploration_latest_run_id).toBe('new');
|
||||
});
|
||||
});
|
||||
@@ -107,10 +107,38 @@ export class AgentService {
|
||||
const filter = this.batchFilter();
|
||||
if (!filter) return true;
|
||||
if (filter === '__batch__') return !!session.batch_id;
|
||||
if (filter === '__single__') return !session.batch_id;
|
||||
if (filter === '__single__') return !session.batch_id && session.exploration_kind !== 'exploration';
|
||||
if (filter === '__exploration__') return session.exploration_kind === 'exploration';
|
||||
return session.batch_id === filter;
|
||||
}
|
||||
|
||||
public explorationSessionFields(item: any): Partial<Session> {
|
||||
if (!item?.exploration_id) return {};
|
||||
return {
|
||||
exploration_id:item.exploration_id, exploration_kind:item.exploration_kind,
|
||||
exploration_latest_run_id:item.exploration_latest_run_id, exploration_status:item.exploration_status,
|
||||
exploration_run_count:item.exploration_run_count, case_version:item.case_version,
|
||||
...(item.display_name ? {display_name:item.display_name} : {}),
|
||||
...(item.exploration_kind === 'exploration' ? {model_info:{name:'探索',id:item.exploration_model,provider:'openai',architecture:'explore'}} : {})
|
||||
};
|
||||
}
|
||||
public isQueueRepresentative(session: Session): boolean {
|
||||
return session.exploration_kind !== 'exploration' || !session.exploration_latest_run_id || session.exploration_latest_run_id === session.session_id;
|
||||
}
|
||||
public explorationQueueStatus(session: Session): string | null {
|
||||
if (session.exploration_kind !== 'exploration') return null;
|
||||
const status = session.exploration_status;
|
||||
if (status === 'finalizing') return 'running';
|
||||
if (['paused','waiting_user','demonstrating','analyzing_demo','interrupted'].includes(status || '')) return 'paused';
|
||||
if (['draft','queued','reserved'].includes(status || '')) return 'pending';
|
||||
return status || null;
|
||||
}
|
||||
|
||||
public explorationStatusLabel(session: Session): string | null {
|
||||
if(session.exploration_kind !== 'exploration') return null;
|
||||
return ({waiting_user:'等待你回答',demonstrating:'人工示范中',analyzing_demo:'分析示范中',paused:'已暂停 · 可继续',finalizing:'正在提炼用例',running:'探索中',completed:'探索结束'} as Record<string,string>)[session.exploration_status || ''] || null;
|
||||
}
|
||||
|
||||
public applyCaseReview(review: CaseReviewResponse): void {
|
||||
this.rawSessions.update(items => items.map(session => session.session_id === review.id
|
||||
? {...session, test_result: review.test_result} : session));
|
||||
@@ -164,7 +192,7 @@ export class AgentService {
|
||||
batch_id: s.batch_id || activeMatch?.batch_id || pendingMatch?.batch_id || null,
|
||||
status: sStatus,
|
||||
device_serial: serial,
|
||||
model_info: isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info
|
||||
model_info: !s.exploration_id && isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info
|
||||
};
|
||||
sessionMap.set(s.session_id, finalSession);
|
||||
if (isTerminal) {
|
||||
@@ -198,13 +226,14 @@ export class AgentService {
|
||||
const existing = sessionMap.get(sid);
|
||||
if (!existing) {
|
||||
const newSession: Session = {
|
||||
...this.explorationSessionFields(at),
|
||||
session_id: sid,
|
||||
batch_id: at.batch_id || null,
|
||||
display_name: at.display_name,
|
||||
initial_goal: at.goal || goal || '',
|
||||
start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
|
||||
status: at.status || 'running',
|
||||
model_info: this.activeModel() || undefined,
|
||||
model_info: this.explorationSessionFields(at).model_info || this.activeModel() || undefined,
|
||||
device_serial: at.device_id || null
|
||||
};
|
||||
sessionMap.set(sid, newSession);
|
||||
@@ -1630,6 +1659,7 @@ export class AgentService {
|
||||
const pending: Session[] = (data.queue || []).map((item: any, index: number) => {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
return {
|
||||
...this.explorationSessionFields(item),
|
||||
session_id: item.session_id || `pending-task-${index}`,
|
||||
batch_id: item.batch_id || null,
|
||||
display_name: item.display_name,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { CaseTestResult } from '../core/models/session.model';
|
||||
|
||||
export interface ExplorationConfig {
|
||||
model: string; reasoning_effort: string; timeout_seconds: number;
|
||||
demonstration_timeout_seconds: number; demonstration_warning_seconds: number;
|
||||
}
|
||||
export interface ExplorationMessage { id: string; sequence: number; role: string; text: string; recommendation?: string; kind?: string; created_at: number; run_id?: string; }
|
||||
export interface ExplorationCase { version: number; name: string; goal: string; preconditions: string; evidence: string[]; source_run_id?: string; }
|
||||
export interface ExplorationRun { id: string; kind: 'exploration' | 'validation'; status: string; user_guidance_count?: number; case_version?: number; test_result?: CaseTestResult | null; created_at: number; task_config: Record<string, unknown>; }
|
||||
export interface Demonstration { id: string; run_id: string; source: string; status: string; created_at?: number; started_at?: number; finished_at?: number; deadline?: number; warning_seconds: number; error?: string; analysis?: {summary: string}; media: {kind: string; segments: {file: string}[]}[]; }
|
||||
export interface ExplorationSession {
|
||||
id: string; goal: string; status: string; config: ExplorationConfig; active_run_id: string | null;
|
||||
active_demonstration_id: string | null; case_version: number; message_count: number;
|
||||
task_config?: Record<string, unknown>;
|
||||
elapsed_seconds: number; continuation_count: number; pending_question?: string; pause_reason?: string;
|
||||
messages: ExplorationMessage[]; runs: ExplorationRun[]; case: ExplorationCase | null; demonstrations: Demonstration[];
|
||||
generations: {id: string; status: string; error?: string}[];
|
||||
}
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class ExplorationService {
|
||||
private http = inject(HttpClient);
|
||||
drafts = new Map<string, string>();
|
||||
requests = new Map<string, string>();
|
||||
caseDrafts = new Map<string, {name: string; goal: string; preconditions: string; version: number}>();
|
||||
private base = '/api/explorations';
|
||||
list() { return firstValueFrom(this.http.get<ExplorationSession[]>(this.base)); }
|
||||
settings() { return firstValueFrom(this.http.get<ExplorationConfig>(this.base + '/settings')); }
|
||||
saveSettings(value: ExplorationConfig) { return firstValueFrom(this.http.put<ExplorationConfig>(this.base + '/settings', value)); }
|
||||
get(id: string, after = 0) { return firstValueFrom(this.http.get<ExplorationSession>(`${this.base}/${encodeURIComponent(id)}?after=${after}`)); }
|
||||
create(goal: string, config: ExplorationConfig | undefined, task_config: Record<string, unknown>, request_id: string) {
|
||||
return firstValueFrom(this.http.post<ExplorationSession>(this.base, {goal, config, task_config, request_id}));
|
||||
}
|
||||
act<T = Record<string, unknown>>(id: string, action: string, payload: Record<string, unknown> = {}, requestId: string = crypto.randomUUID()) {
|
||||
return firstValueFrom(this.http.post<T>(`${this.base}/${encodeURIComponent(id)}/${action}`, {...payload, request_id: requestId}));
|
||||
}
|
||||
version(id: string, version: number) { return firstValueFrom(this.http.get<ExplorationCase>(`${this.base}/${encodeURIComponent(id)}/case?version=${version}`)); }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { mergeConversation } from './exploration-timeline.util';
|
||||
describe('Exploration timeline',()=>{
|
||||
it('interleaves guidance without charging chat time or tokens to operations',()=>{
|
||||
const operation={id:'step',durationSeconds:8,tokens:100,blocks:[{id:'b',type:'step' as const,timestamp:new Date(20000).toISOString(),data:{operator_raw_thinking:'准备动作。 完成,已确认页面。'}}]};
|
||||
const messages=[{id:'a',sequence:1,role:'user',text:'先观察',created_at:10},{id:'b',sequence:2,role:'assistant',text:'完成,已确认页面。',created_at:21},{id:'c',sequence:3,role:'assistant',text:'下一步?',recommendation:'继续',created_at:22}];
|
||||
const result=mergeConversation([operation],messages);
|
||||
expect(result.map(p=>p.id)).toEqual(['message-a','step','message-c']);expect(result[1]).toBe(operation);expect(result[1].durationSeconds).toBe(8);expect(result[1].tokens).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { PhaseBlock } from '../core/models/stream.model';
|
||||
import { ExplorationMessage, Demonstration } from '../services/exploration.service';
|
||||
|
||||
export type ConversationPhase = PhaseBlock & {conversation?: ExplorationMessage; demonstration?: Demonstration};
|
||||
/** Keep step accounting unchanged when inserting messages between real operations. */
|
||||
export function mergeConversation(phases: PhaseBlock[], messages: ExplorationMessage[], demonstrations: Demonstration[] = []): ConversationPhase[] {
|
||||
const normalize = (text: string) => text.replace(/\s+/g, ' ').trim();
|
||||
const texts = phases.flatMap(p => p.blocks.flatMap(b => [b.data?.operator_raw_thinking, b.data?.text])).filter(Boolean).map(t => normalize(String(t)));
|
||||
const entries: ConversationPhase[] = [...phases];
|
||||
for (const message of messages) {
|
||||
if (message.role === 'assistant' && !message.recommendation && texts.some(t => t.includes(normalize(message.text)))) continue;
|
||||
entries.push({id: 'message-' + message.id, durationSeconds: 0, blocks: [], conversation: message});
|
||||
}
|
||||
for (const demo of demonstrations) entries.push({id: 'demo-' + demo.id, durationSeconds: 0, blocks: [], demonstration: demo});
|
||||
return entries.sort((a,b) => {
|
||||
const time = (p: ConversationPhase) => p.demonstration ? (p.demonstration.started_at || p.demonstration.created_at || 0) * 1000 : p.conversation ? p.conversation.created_at * 1000 : Date.parse(p.blocks[0]?.timestamp || '') || 0;
|
||||
return time(a) - time(b) || (a.conversation?.sequence || 0) - (b.conversation?.sequence || 0);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user