From ecd6974e3bb1a2085ae917ec0223f39ae99fc3ba Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Wed, 5 Aug 2026 01:15:06 +0800 Subject: [PATCH 01/11] Fix tool_call_id loss in unified memory message round-trip --- ms_agent/memory/unified/orchestrator.py | 37 +++++++++++++------------ tests/memory/test_unified_memory.py | 25 +++++++++++++++++ 2 files changed, 45 insertions(+), 17 deletions(-) diff --git a/ms_agent/memory/unified/orchestrator.py b/ms_agent/memory/unified/orchestrator.py index 000a643be..789096a88 100644 --- a/ms_agent/memory/unified/orchestrator.py +++ b/ms_agent/memory/unified/orchestrator.py @@ -12,6 +12,9 @@ from ms_agent.llm.utils import Message from ms_agent.memory.base import Memory +# Single canonical deserializer, shared with ContextAssembler. A second local +# copy previously drifted and silently dropped `tool_call_id` / `name`. +from ms_agent.session.context_assembler import _dicts_to_messages from ms_agent.utils.logger import get_logger from .config import MemoryConfig from .protocols import MemoryBackend, MemoryEntry @@ -181,6 +184,15 @@ def _default_base_dir(mc: MemoryConfig, config: Any) -> None: def _messages_to_dicts(messages: List[Message]) -> List[Dict[str, Any]]: + """Serialize for ``backend.inject()`` — must be lossless. + + This round-trip (``run()``: messages -> dicts -> inject -> messages) runs on + EVERY turn, so any field dropped here is dropped from the live LLM context, + not just from storage. ``tool_call_id`` in particular is mandatory on the + wire for ``role='tool'`` rows: losing it makes OpenAI-compatible providers + reject the request with ``missing field 'tool_call_id'``. Keep this the + exact inverse of ``_dicts_to_messages`` below. + """ result: List[Dict[str, Any]] = [] for m in messages: if isinstance(m, dict): @@ -189,24 +201,15 @@ def _messages_to_dicts(messages: List[Message]) -> List[Dict[str, Any]]: d: Dict[str, Any] = {'role': m.role, 'content': m.content or ''} if m.tool_calls: d['tool_calls'] = m.tool_calls + if m.tool_call_id: + d['tool_call_id'] = m.tool_call_id + if m.name: + d['name'] = m.name + if m.reasoning_content: + d['reasoning_content'] = m.reasoning_content + if m.reasoning_signature: + d['reasoning_signature'] = m.reasoning_signature result.append(d) else: result.append({'role': 'user', 'content': str(m)}) return result - - -def _dicts_to_messages(dicts: List[Dict[str, Any]]) -> List[Message]: - result: List[Message] = [] - for d in dicts: - if isinstance(d, Message): - result.append(d) - elif isinstance(d, dict): - result.append( - Message( - role=d.get('role', 'user'), - content=d.get('content', ''), - tool_calls=d.get('tool_calls'), - )) - else: - result.append(Message(role='user', content=str(d))) - return result diff --git a/tests/memory/test_unified_memory.py b/tests/memory/test_unified_memory.py index 2063b5949..e8391d797 100644 --- a/tests/memory/test_unified_memory.py +++ b/tests/memory/test_unified_memory.py @@ -262,6 +262,31 @@ def test_dicts_to_messages_preserves_tool_fields(self): assert msgs[1].tool_call_id == "call_1" assert msgs[1].name == "search" + def test_memory_orchestrator_round_trip_preserves_tool_fields(self): + """The unified-memory round-trip runs on EVERY turn, in-memory, before + the LLM call — so a field dropped there is dropped from the live + request. This previously regressed independently of the assembler + (the test above imported the assembler's copy and never covered it), + and OpenAI-compatible providers answered with + ``missing field 'tool_call_id'``. + """ + from ms_agent.memory.unified.orchestrator import (_dicts_to_messages, + _messages_to_dicts) + from ms_agent.llm.utils import Message + + msgs = [ + Message(role="assistant", content="", + tool_calls=[{"id": "call_1", "type": "function"}]), + Message(role="tool", content="result", + tool_call_id="call_1", name="search"), + ] + out = _dicts_to_messages(_messages_to_dicts(msgs)) + assert out[1].tool_call_id == "call_1" + assert out[1].name == "search" + # And it must actually reach the wire: to_dict_clean() drops falsy + # values, so a lost id disappears from the payload entirely. + assert out[1].to_dict_clean()["tool_call_id"] == "call_1" + # ═══════════════════════════════════════════════════════════════════════ # 2. ViewStrategies From f8ce706129b49b1c77e0ad84afff3c345f27acfd Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Wed, 5 Aug 2026 01:15:50 +0800 Subject: [PATCH 02/11] Pair tool results with pending calls when tool_call_id is missing in openai-compat transport --- ms_agent/llm/transport/openai_compat.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/ms_agent/llm/transport/openai_compat.py b/ms_agent/llm/transport/openai_compat.py index 3e7779c47..d25316419 100644 --- a/ms_agent/llm/transport/openai_compat.py +++ b/ms_agent/llm/transport/openai_compat.py @@ -171,6 +171,14 @@ def _format_input_message(self, cache_indice = max(cache_indices) if cache_indices else None openai_messages = [] + # Order-matched fallback for a tool row that reaches us without its id. + # OpenAI-compatible gateways reject such a message outright + # ("missing field `tool_call_id`"), killing the whole turn, so pair it + # with the preceding assistant turn's calls instead. The Anthropic + # transport has carried the same guard for a while; this keeps the two + # symmetric. Note `to_dict_clean()` omits falsy values, so a None/'' id + # disappears from the dict entirely rather than arriving as None. + pending_tool_ids: List[str] = [] for idx, message in enumerate(messages): if isinstance(message, Message): if isinstance(message.content, str): @@ -207,6 +215,22 @@ def _format_input_message(self, and not content): formatted_message['content'] = None + role = formatted_message.get('role') + if role == 'assistant': + pending_tool_ids = [ + tc.get('id') for tc in (formatted_message.get('tool_calls') + or []) if isinstance(tc, dict) + and tc.get('id') + ] + elif role == 'tool' and not formatted_message.get('tool_call_id'): + if pending_tool_ids: + formatted_message['tool_call_id'] = pending_tool_ids.pop(0) + else: + logger.warning( + 'tool message has no tool_call_id and no preceding ' + 'assistant tool_calls to match it against; the provider ' + 'will likely reject this request') + openai_messages.append(formatted_message) return openai_messages From a7b86ec557005db1be8f72d716a433bea22cb6ca Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Wed, 5 Aug 2026 01:15:52 +0800 Subject: [PATCH 03/11] Seal errored rounds so resume consumes the next prompt instead of replaying --- ms_agent/agent/llm_agent.py | 65 +++++++++++++++++++++++-------- tests/agent/test_partial_round.py | 64 ++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 17 deletions(-) diff --git a/ms_agent/agent/llm_agent.py b/ms_agent/agent/llm_agent.py index 2dd2b905a..a7364b731 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -59,8 +59,15 @@ _INTERRUPTED_TOOL_RESULT = '[Interrupted: tool execution was cancelled]' -def build_partial_round_records(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Turn an interrupted round's in-memory rows into protocol-valid log records. +def build_partial_round_records( + rows: List[Dict[str, Any]], + marker: str = 'interrupted') -> List[Dict[str, Any]]: + """Turn an unfinished round's in-memory rows into protocol-valid log records. + + ``marker`` is the boolean key stamped on every produced record, naming *why* + the round ended early: ``interrupted`` (user Stop / cancellation) or + ``errored`` (the turn raised). UIs badge these differently, so an API + failure must not be sealed as an interruption. ``rows`` are the ``_msg_to_dict`` serializations of ``messages[pre_step_len:]`` at cancellation time. Rules (each keeps replay valid on BOTH transports): @@ -79,7 +86,7 @@ def build_partial_round_records(rows: List[Dict[str, Any]]) -> List[Dict[str, An with no content and no kept calls gets the neutral placeholder content so the turn reads as closed (an empty assistant block would be rejected on Anthropic replay, and a dangling user row would be re-answered on resume). - - every row is flagged ``interrupted: true`` — an extra key that survives in + - every row is flagged ``: true`` — an extra key that survives in the log for UI replay but is filtered out of the LLM context rebuild. """ present_results = { @@ -89,7 +96,7 @@ def build_partial_round_records(rows: List[Dict[str, Any]]) -> List[Dict[str, An records: List[Dict[str, Any]] = [] for row in rows: rec = dict(row) - rec['interrupted'] = True + rec[marker] = True if rec.get('role') != 'assistant': records.append(rec) continue @@ -130,14 +137,14 @@ def build_partial_round_records(rows: List[Dict[str, Any]]) -> List[Dict[str, An 'tool_call_id': tc.get('id'), 'name': tc.get('tool_name', ''), 'is_error': True, - 'interrupted': True, + marker: True, }) if not records: records.append({ 'role': 'assistant', 'content': INTERRUPTED_PLACEHOLDER, 'content_placeholder': True, - 'interrupted': True, + marker: True, }) return records @@ -1980,16 +1987,24 @@ def _msg_to_dict(msg: Message) -> Dict[str, Any]: d['tokens'] = prompt_tokens + completion_tokens return d - def _persist_partial_round(self, messages: List[Message], - pre_step_len: int) -> None: - """Seal an interrupted round into the SessionLog (best-effort). - - Called from run_loop's cancellation handler, where the normal - round-boundary persistence can no longer run. Serializes the round's - in-memory rows and appends the protocol-repaired records built by - :func:`build_partial_round_records`. Synchronous file I/O only (safe - inside a cancelled task); never raises — sealing must not break the - cancellation unwind. + def _persist_partial_round(self, + messages: List[Message], + pre_step_len: int, + marker: str = 'interrupted') -> None: + """Seal an unfinished round into the SessionLog (best-effort). + + Called from run_loop's cancellation handler AND its exception handler, + where the normal round-boundary persistence can no longer run. + Serializes the round's in-memory rows and appends the protocol-repaired + records built by :func:`build_partial_round_records`. Synchronous file + I/O only (safe inside a cancelled task); never raises — sealing must not + break the unwind. + + Sealing is not cosmetic: without it the log tail stays on a ``user`` or + ``tool`` row, and the next resume rebuilds that same round and re-calls + the model instead of reading the user's new prompt (see run_loop's + ``load_cache`` guard). ``marker`` records why the round ended — + ``interrupted`` for Stop, ``errored`` for a raised turn. """ if self.session_log is None: return @@ -2018,7 +2033,7 @@ def _persist_partial_round(self, messages: List[Message], rows = [ self._msg_to_dict(msg) for msg in messages[pre_step_len:] ] - for record in build_partial_round_records(rows): + for record in build_partial_round_records(rows, marker=marker): self.session_log.append(record) except Exception: logger.warning('persist partial round failed', exc_info=True) @@ -2034,6 +2049,11 @@ async def run_loop(self, messages: Union[List[Message], str], Args: messages: Input prompt string or list of Message objects. """ + # Bound BEFORE the try so the exception handler can always read it: + # setup (agent/LLM construction, credential resolution) raises well + # before the round loop, and an unbound local there would turn a clean + # provider error into an UnboundLocalError. None = no round to seal. + pre_step_len: Optional[int] = None try: self.max_chat_round = getattr(self.config, 'max_chat_round', LLMAgent.DEFAULT_MAX_CHAT_ROUND) @@ -2266,6 +2286,17 @@ async def run_loop(self, messages: Union[List[Message], str], import traceback logger.warning(traceback.format_exc()) + # Seal the round FIRST. A raised turn leaves the log tail on a + # `user`/`tool` row, and run_loop's resume guard only skips the + # model call when the tail is `assistant` — so the next request + # rebuilds this same round, re-sends the identical failing context, + # and never reaches after_tool_call (which is what drains the queued + # prompt). The user sees the turn "loop" while every resend is + # silently discarded. Sealing closes the round, so the rebuilt agent + # blocks on read_prompt and consumes the new message instead. + if pre_step_len is not None: + self._persist_partial_round( + messages, pre_step_len, marker='errored') if self._event_sink is not None: # A run_loop turn-abort is non-recoverable (the turn produced no # usable assistant output); mark it so live == persisted/replay. diff --git a/tests/agent/test_partial_round.py b/tests/agent/test_partial_round.py index 75d17d478..b0281dff8 100644 --- a/tests/agent/test_partial_round.py +++ b/tests/agent/test_partial_round.py @@ -185,3 +185,67 @@ def test_reasoning_duration_finalized_when_cancelled_mid_reasoning(): import time dur = _persisted_reasoning_duration(time.monotonic() - 3.0, None) assert dur is not None and dur >= 2 + + +# --- sealing a round that RAISED (not a user Stop) ----------------------- + + +def test_errored_marker_replaces_interrupted(): + # run_loop's exception handler seals with marker='errored'. UIs badge + # `interrupted` as a user Stop, so an API failure must not borrow that key. + records = build_partial_round_records([], marker='errored') + assert records == [{ + 'role': 'assistant', + 'content': INTERRUPTED_PLACEHOLDER, + 'content_placeholder': True, + 'errored': True, + }] + assert 'interrupted' not in records[0] + + +def test_errored_marker_applies_to_synthesized_tool_results(): + records = build_partial_round_records( + [{'role': 'assistant', 'content': '', + 'tool_calls': [{'id': 'c1', 'arguments': '{}', 'tool_name': 'grep'}]}], + marker='errored') + assert [r['role'] for r in records] == ['assistant', 'tool'] + assert all(r.get('errored') is True for r in records) + assert not any('interrupted' in r for r in records) + + +def test_error_seal_closes_dangling_tool_tail(): + """The regression that made failed turns look like an infinite loop. + + A raised turn used to leave the log tail on a `tool` row. run_loop's resume + guard only skips the model call when the tail is `assistant`, so the next + request replayed the same round with the same failing context and never + drained the queued prompt — every resend was silently dropped. + """ + from ms_agent.agent.llm_agent import LLMAgent + + class _Log: + + def __init__(self, rows): + self.rows = list(rows) + + def append(self, rec): + self.rows.append(rec) + return len(self.rows) + + # Tail as persisted by the round that then blew up on the next LLM call. + stub = LLMAgent.__new__(LLMAgent) + stub.session_log = _Log([ + {'role': 'user', 'content': '搜一下今天的热点新闻'}, + {'role': 'assistant', 'content': '', + 'tool_calls': [{'id': 'c1', 'arguments': '{}', 'tool_name': 'search'}]}, + {'role': 'tool', 'content': 'results', 'tool_call_id': 'c1'}, + ]) + stub._reasoning_started_at = None + stub._last_reasoning_duration = None + assert stub.session_log.rows[-1]['role'] == 'tool' # the poisoned state + + # step() raised before yielding anything -> empty segment. + LLMAgent._persist_partial_round(stub, [], 0, marker='errored') + + assert stub.session_log.rows[-1]['role'] == 'assistant' + assert stub.session_log.rows[-1]['errored'] is True From 0686305ffe91df4158748a1f6909680924a7e6cf Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Wed, 5 Aug 2026 01:16:18 +0800 Subject: [PATCH 04/11] Skip LLM call retries for non-retryable 4xx client errors --- ms_agent/agent/llm_agent.py | 9 +- ms_agent/utils/__init__.py | 2 +- ms_agent/utils/llm_utils.py | 61 ++++++++++++- tests/utils/test_retry_classification.py | 109 +++++++++++++++++++++++ 4 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 tests/utils/test_retry_classification.py diff --git a/ms_agent/agent/llm_agent.py b/ms_agent/agent/llm_agent.py index a7364b731..03d30a0fb 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -40,7 +40,8 @@ ReasoningDelta, ReasoningEnded, ReasoningStarted, ToolCallCompleted, ToolCallStarted, TurnCompleted, UsageInfo) -from ms_agent.utils import async_retry, read_history, save_history +from ms_agent.utils import (async_retry, is_retryable_error, read_history, + save_history) from ms_agent.utils.constants import DEFAULT_TAG, DEFAULT_USER from ms_agent.utils.logger import get_logger from ms_agent.utils.snapshot import take_snapshot @@ -1535,7 +1536,11 @@ def _append_task_notifications(self, messages.append(Message(role='user', content=body)) return messages - @async_retry(max_attempts=Agent.retry_count, delay=1.0) + # retry_if: a hard 4xx (bad payload, content filter, auth) is a verdict on + # the request, not a transient fault — retrying it 5× only adds ~40s of + # backoff before the same failure surfaces. + @async_retry( + max_attempts=Agent.retry_count, delay=1.0, retry_if=is_retryable_error) async def step( self, messages: List[Message] ) -> AsyncGenerator[List[Message], Any]: # type: ignore diff --git a/ms_agent/utils/__init__.py b/ms_agent/utils/__init__.py index 32655bd93..afa87e941 100644 --- a/ms_agent/utils/__init__.py +++ b/ms_agent/utils/__init__.py @@ -1,5 +1,5 @@ # Copyright (c) ModelScope Contributors. All rights reserved. -from .llm_utils import async_retry, retry +from .llm_utils import async_retry, is_retryable_error, retry from .logger import get_logger from .prompt import get_fact_retrieval_prompt from .utils import (assert_package_exist, enhance_error, read_history, diff --git a/ms_agent/utils/llm_utils.py b/ms_agent/utils/llm_utils.py index 4f800bd45..7d0611bc7 100644 --- a/ms_agent/utils/llm_utils.py +++ b/ms_agent/utils/llm_utils.py @@ -1,8 +1,10 @@ # Copyright (c) ModelScope Contributors. All rights reserved. import asyncio import functools +import re import time -from typing import Any, AsyncGenerator, Callable, Tuple, Type, TypeVar, Union +from typing import (Any, AsyncGenerator, Callable, Optional, Tuple, Type, + TypeVar, Union) from .logger import get_logger @@ -10,6 +12,47 @@ T = TypeVar('T') +# 4xx statuses that CAN succeed on a resend: request timeout, lock conflict, +# too-early, and rate limiting. Every other 4xx is a verdict on the request +# itself — resending the identical payload just buys the same rejection. +_RETRYABLE_CLIENT_STATUSES = frozenset({408, 409, 425, 429}) + +# Providers surface the status inconsistently: the openai/anthropic SDKs expose +# `status_code`, while gateways often only put it in the message +# ("APIError: <400> ...", "Error code: 400 - {...}"). +_STATUS_IN_TEXT = re.compile(r'<(\d{3})>|(?:error\s+)?code[:=]?\s*(\d{3})\b', + re.IGNORECASE) + + +def http_status_of(exc: BaseException) -> Optional[int]: + """Best-effort HTTP status for a provider exception, or None if unknown.""" + for attr in ('status_code', 'http_status', 'status'): + value = getattr(exc, attr, None) + if isinstance(value, int) and 100 <= value <= 599: + return value + match = _STATUS_IN_TEXT.search(str(exc)) + if match: + status = int(match.group(1) or match.group(2)) + if 100 <= status <= 599: + return status + return None + + +def is_retryable_error(exc: BaseException) -> bool: + """Whether resending the same request could plausibly succeed. + + Unknown/None status keeps the historical behaviour (retry), so transient + network faults are unaffected. Only an identifiable, non-transient 4xx + fails fast — previously a hard 400 (bad payload, content filter, missing + field) burned all five attempts plus 15s of backoff before surfacing. + """ + status = http_status_of(exc) + if status is None: + return True + if 400 <= status < 500: + return status in _RETRYABLE_CLIENT_STATUSES + return True + def retry(max_attempts: int = 3, delay: float = 1.0, @@ -54,8 +97,14 @@ def async_retry(max_attempts: int = 3, delay: float = 1.0, backoff_factor: float = 2.0, exceptions: Union[Type[Exception], Tuple[Type[Exception], - ...]] = Exception): - """Retry doing something""" + ...]] = Exception, + retry_if: Optional[Callable[[BaseException], bool]] = None): + """Retry doing something. + + ``retry_if`` short-circuits the loop for exceptions that cannot succeed on + a resend (see :func:`is_retryable_error`); the exception is still raised, + just without burning the remaining attempts and their backoff. + """ def decorator(func: Callable[..., T]) -> Callable[..., T]: @@ -73,6 +122,12 @@ async def wrapper(*args, **kwargs) -> AsyncGenerator[T, Any]: import traceback logger.warning(traceback.format_exc()) last_exception = e + if retry_if is not None and not retry_if(e): + logger.error( + f'{func.__name__} failed unrecoverably on attempt ' + f'{attempt}/{max_attempts}; not retrying. ' + f'Exception message: {e}') + break if attempt < max_attempts: logger.warning( f'Attempt {attempt}/{max_attempts} fails: {func.__name__}. ' diff --git a/tests/utils/test_retry_classification.py b/tests/utils/test_retry_classification.py new file mode 100644 index 000000000..79cc3c969 --- /dev/null +++ b/tests/utils/test_retry_classification.py @@ -0,0 +1,109 @@ +"""async_retry's ``retry_if`` gate: don't burn attempts on unrecoverable 4xx. + +A hard 400 (bad payload, missing field, content filter) is a verdict on the +request — resending the identical body five times only adds ~15s of backoff +before the same failure surfaces. Transient faults must keep retrying. +""" +import asyncio + +import pytest +from ms_agent.utils import async_retry, is_retryable_error +from ms_agent.utils.llm_utils import http_status_of + + +class _ApiError(Exception): + """Stand-in for a provider SDK error, with or without ``status_code``.""" + + def __init__(self, message, status_code=None): + super().__init__(message) + if status_code is not None: + self.status_code = status_code + + +@pytest.mark.parametrize( + 'exc, expected', + [ + # Real strings observed in production, where the status is only in text. + (_ApiError('APIError: <400> InternalError.Algo.DataInspectionFailed: ' + 'Output data may contain inappropriate content.'), 400), + (_ApiError("Error code: 400 - {'error': {'message': 'missing field " + "`tool_call_id`'}}"), 400), + # Structured SDK errors. + (_ApiError('boom', 429), 429), + (_ApiError('boom', 503), 503), + # Nothing status-like. + (ConnectionError('Connection reset by peer'), None), + ], +) +def test_http_status_extraction(exc, expected): + assert http_status_of(exc) == expected + + +@pytest.mark.parametrize('status', [400, 401, 403, 404, 422]) +def test_client_errors_are_not_retryable(status): + assert is_retryable_error(_ApiError('boom', status)) is False + + +@pytest.mark.parametrize('status', [408, 409, 425, 429, 500, 502, 503]) +def test_transient_and_server_errors_stay_retryable(status): + assert is_retryable_error(_ApiError('boom', status)) is True + + +@pytest.mark.parametrize( + 'exc', + [ConnectionError('reset'), + TimeoutError('timed out'), + Exception('something odd')]) +def test_unknown_errors_keep_retrying(exc): + # Conservative default: an unidentifiable failure behaves as before. + assert is_retryable_error(exc) is True + + +def _run(fn): + async def drive(): + async for _ in fn(): + pass + + with pytest.raises(Exception): + asyncio.run(drive()) + + +def test_unrecoverable_error_uses_exactly_one_attempt(): + calls = [] + + @async_retry(max_attempts=5, delay=10.0, retry_if=is_retryable_error) + async def boom(): + calls.append(1) + raise _ApiError('APIError: <400> DataInspectionFailed') + yield # pragma: no cover - makes this an async generator + + _run(boom) + # delay=10.0 would make a retrying implementation take >=10s; one attempt + # also proves no backoff was slept. + assert len(calls) == 1 + + +def test_transient_error_still_exhausts_attempts(): + calls = [] + + @async_retry(max_attempts=3, delay=0.01, retry_if=is_retryable_error) + async def flaky(): + calls.append(1) + raise ConnectionError('reset') + yield # pragma: no cover + + _run(flaky) + assert len(calls) == 3 + + +def test_without_retry_if_behaviour_is_unchanged(): + calls = [] + + @async_retry(max_attempts=3, delay=0.01) + async def boom(): + calls.append(1) + raise _ApiError('APIError: <400> DataInspectionFailed') + yield # pragma: no cover + + _run(boom) + assert len(calls) == 3 From 977d16b9f4fddb96557c19669f631e39be2f31fe Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Wed, 5 Aug 2026 01:16:22 +0800 Subject: [PATCH 05/11] Dedupe identical per-round error records in SessionLog --- ms_agent/session/session_log.py | 13 ++++++++++ tests/memory/test_unified_memory.py | 37 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/ms_agent/session/session_log.py b/ms_agent/session/session_log.py index 84f922aab..8b8e4c6aa 100644 --- a/ms_agent/session/session_log.py +++ b/ms_agent/session/session_log.py @@ -103,7 +103,20 @@ def record_error(self, event: Dict[str, Any]) -> None: turn-level / API errors that must NOT go back to the model (tool-call errors stay as ordinary ``role="tool"`` messages instead). ``event`` typically carries ``message``, ``error_type``, ``recoverable``, ``round``. + + Idempotent per (round, message): a round that is retried or replayed + must not stack identical records. A wedged turn used to append one per + attempt, growing a log of dozens of copies of the same failure and + making replay unreadable. Dedup needs a ``round`` to key on; without one + every call is recorded (callers that omit it are opting out). """ + rnd = event.get('round') + if rnd is not None: + message = event.get('message') + for prior in self.get_errors(): + if (prior.get('round') == rnd + and prior.get('message') == message): + return seq = self._next_seq() record = { "_type": "error", diff --git a/tests/memory/test_unified_memory.py b/tests/memory/test_unified_memory.py index e8391d797..6f92cb347 100644 --- a/tests/memory/test_unified_memory.py +++ b/tests/memory/test_unified_memory.py @@ -1696,3 +1696,40 @@ def test_session_and_memory_pipeline(self): assert len(log.get_all_messages()) == 4 finally: loop.close() + + +class TestSessionLogErrorDedup: + """record_error is idempotent per (round, message). + + A wedged turn used to append one identical record per retry/replay attempt + (observed: five copies of the same 400, all round 15), making replay + unreadable. Callers that omit ``round`` opt out of dedup on purpose — they + have no round identity to key on. + """ + + def setup_method(self): + import tempfile + self.tmpdir = tempfile.mkdtemp() + + def test_same_round_same_message_recorded_once(self): + log = SessionLog(self.tmpdir, session_key="err_dedup") + for _ in range(5): + log.record_error({ + "message": "APIError: <400> boom", + "recoverable": False, + "round": 15, + }) + assert len(log.get_errors()) == 1 + + def test_round_and_message_changes_are_kept(self): + log = SessionLog(self.tmpdir, session_key="err_kept") + log.record_error({"message": "APIError: boom", "round": 15}) + log.record_error({"message": "APIError: boom", "round": 16}) + log.record_error({"message": "different failure", "round": 15}) + assert len(log.get_errors()) == 3 + + def test_callers_without_a_round_opt_out_of_dedup(self): + log = SessionLog(self.tmpdir, session_key="err_optout") + log.record_error({"message": "no round identity"}) + log.record_error({"message": "no round identity"}) + assert len(log.get_errors()) == 2 From 4b5f976039b88938b9f2162272dbea88065825ce Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Wed, 5 Aug 2026 01:16:24 +0800 Subject: [PATCH 06/11] Use native Windows shell semantics in the local code executor --- ms_agent/tools/code/local_code_executor.py | 19 +++++++- .../tools/test_local_code_executor_windows.py | 47 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 tests/tools/test_local_code_executor_windows.py diff --git a/ms_agent/tools/code/local_code_executor.py b/ms_agent/tools/code/local_code_executor.py index 261859278..e46a6911f 100644 --- a/ms_agent/tools/code/local_code_executor.py +++ b/ms_agent/tools/code/local_code_executor.py @@ -332,6 +332,18 @@ def _build_env(self, field: str, inherit: bool = False) -> Dict[str, str]: 'HOME': os.environ.get('HOME', ''), 'LANG': os.environ.get('LANG', ''), } + if os.name == 'nt': + # ``create_subprocess_shell`` uses the native Windows command + # processor. Keep the non-secret OS/user variables that cmd + # and programs using temporary/profile directories require. + for key in ( + 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT', 'TEMP', + 'TMP', 'TMPDIR', 'USERPROFILE', 'HOMEDRIVE', + 'HOMEPATH', 'USERNAME', 'APPDATA', 'LOCALAPPDATA', + 'PROGRAMDATA', 'OS', 'PROCESSOR_ARCHITECTURE'): + value = os.environ.get(key) + if value is not None: + env[key] = value if not self.tool_config or not hasattr(self.tool_config, field): return env @@ -589,7 +601,12 @@ async def call_tool(self, server_name: str, *, tool_name: str, indent=2) def _prepare_shell_command(self, command: str) -> str: - """Wrap composite shell input in ``sh -lc`` when needed (matches sandbox behavior).""" + """Use the native Windows shell; wrap composite POSIX input when needed.""" + if os.name == 'nt': + # asyncio.create_subprocess_shell delegates to cmd.exe on Windows; + # wrapping native syntax in a usually unavailable POSIX shell + # makes otherwise valid compound commands fail. + return command shell_meta = ('&&', '||', '|', ';', '>', '<', '`', '$(', 'cd ', 'export ') already_wrapped = command.lstrip().startswith( diff --git a/tests/tools/test_local_code_executor_windows.py b/tests/tools/test_local_code_executor_windows.py new file mode 100644 index 000000000..8ba819c6e --- /dev/null +++ b/tests/tools/test_local_code_executor_windows.py @@ -0,0 +1,47 @@ +import os +from unittest import mock + +from ms_agent.tools.code.local_code_executor import LocalCodeExecutionTool + + +def _bare_tool() -> LocalCodeExecutionTool: + """Build a unit-test instance without starting kernels or checking deps.""" + tool = LocalCodeExecutionTool.__new__(LocalCodeExecutionTool) + tool.tool_config = None + return tool + + +def test_composite_command_uses_native_windows_shell(): + command = 'cd work && echo ok > result.txt' + with mock.patch('ms_agent.tools.code.local_code_executor.os.name', 'nt'): + assert _bare_tool()._prepare_shell_command(command) == command + + +def test_sanitized_env_keeps_windows_runtime_variables(): + windows_env = { + 'PATH': r'C:\Windows\System32', + 'SYSTEMROOT': r'C:\Windows', + 'WINDIR': r'C:\Windows', + 'COMSPEC': r'C:\Windows\System32\cmd.exe', + 'PATHEXT': '.COM;.EXE;.BAT;.CMD', + 'TEMP': r'C:\Users\tester\AppData\Local\Temp', + 'TMP': r'C:\Users\tester\AppData\Local\Temp', + 'USERPROFILE': r'C:\Users\tester', + 'HOMEDRIVE': 'C:', + 'HOMEPATH': r'\Users\tester', + 'USERNAME': 'tester', + 'APPDATA': r'C:\Users\tester\AppData\Roaming', + 'LOCALAPPDATA': r'C:\Users\tester\AppData\Local', + 'SECRET_TOKEN': 'must-not-leak', + } + with mock.patch.dict(os.environ, windows_env, clear=True), mock.patch( + 'ms_agent.tools.code.local_code_executor.os.name', 'nt'): + env = _bare_tool()._build_env('shell_env', inherit=False) + + for key in ( + 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT', 'TEMP', 'TMP', + 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'USERNAME', 'APPDATA', + 'LOCALAPPDATA'): + assert env[key] == windows_env[key] + assert env['INHERITED_FROM_LOCAL'] == 'False' + assert 'SECRET_TOKEN' not in env From 2f8641bc5f9163fbe769bb9fee73215928035f52 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Wed, 5 Aug 2026 01:33:40 +0800 Subject: [PATCH 07/11] webui: snapshot ms-agent-webui backend+frontend @ 887100e --- .gitignore | 11 + webui/backend/.env.example | 32 + webui/backend/.python-version | 1 + webui/backend/agent_runner.py | 1689 ------ webui/backend/api.py | 806 --- webui/backend/app/__init__.py | 0 webui/backend/app/api/__init__.py | 0 webui/backend/app/api/agent_settings.py | 45 + webui/backend/app/api/chat.py | 89 + webui/backend/app/api/instructions.py | 45 + webui/backend/app/api/mcps.py | 125 + webui/backend/app/api/memory.py | 100 + webui/backend/app/api/models.py | 78 + webui/backend/app/api/presence.py | 29 + webui/backend/app/api/profile.py | 40 + webui/backend/app/api/projects.py | 138 + webui/backend/app/api/providers.py | 136 + webui/backend/app/api/sessions.py | 118 + webui/backend/app/api/skills.py | 128 + webui/backend/app/api/workspace.py | 274 + webui/backend/app/backends/__init__.py | 22 + webui/backend/app/backends/errors.py | 23 + webui/backend/app/backends/mock.py | 113 + .../backend/app/backends/ms_agent/__init__.py | 1 + .../app/backends/ms_agent/agent_settings.py | 60 + .../backend/app/backends/ms_agent/backend.py | 22 + .../app/backends/ms_agent/bootstrap.py | 142 + webui/backend/app/backends/ms_agent/chat.py | 1308 ++++ webui/backend/app/backends/ms_agent/common.py | 150 + webui/backend/app/backends/ms_agent/config.py | 521 ++ .../app/backends/ms_agent/instructions.py | 57 + .../backend/app/backends/ms_agent/mapping.py | 150 + .../app/backends/ms_agent/mcp_health.py | 108 + webui/backend/app/backends/ms_agent/mcps.py | 235 + webui/backend/app/backends/ms_agent/memory.py | 261 + .../app/backends/ms_agent/model_link.py | 130 + webui/backend/app/backends/ms_agent/models.py | 92 + .../backend/app/backends/ms_agent/profile.py | 31 + .../backend/app/backends/ms_agent/projects.py | 117 + .../app/backends/ms_agent/providers.py | 158 + .../backend/app/backends/ms_agent/runtime.py | 463 ++ .../backend/app/backends/ms_agent/sessions.py | 974 +++ .../app/backends/ms_agent/settings_store.py | 20 + .../backend/app/backends/ms_agent/sidecar.py | 78 + .../app/backends/ms_agent/skill_notice.py | 182 + webui/backend/app/backends/ms_agent/skills.py | 477 ++ webui/backend/app/backends/ms_agent/titler.py | 178 + .../app/backends/ms_agent/workspace.py | 321 + webui/backend/app/core/__init__.py | 0 webui/backend/app/core/envelope.py | 107 + webui/backend/app/core/filetypes.py | 70 + webui/backend/app/core/model_discovery.py | 54 + webui/backend/app/core/settings.py | 60 + webui/backend/app/core/store.py | 508 ++ webui/backend/app/main.py | 101 + webui/backend/app/schemas/__init__.py | 0 webui/backend/app/schemas/agent_settings.py | 19 + webui/backend/app/schemas/chat.py | 79 + webui/backend/app/schemas/instruction.py | 13 + webui/backend/app/schemas/mcp.py | 54 + webui/backend/app/schemas/memory.py | 18 + webui/backend/app/schemas/model.py | 25 + webui/backend/app/schemas/profile.py | 14 + webui/backend/app/schemas/project.py | 46 + webui/backend/app/schemas/provider.py | 43 + webui/backend/app/schemas/session.py | 142 + webui/backend/app/schemas/skill.py | 41 + webui/backend/app/schemas/workspace.py | 39 + webui/backend/config_manager.py | 254 - webui/backend/deep_research_eventizer.py | 433 -- webui/backend/deep_research_worker.py | 378 -- webui/backend/deep_research_worker_manager.py | 207 - webui/backend/main.py | 84 - webui/backend/project_discovery.py | 194 - webui/backend/pyproject.toml | 51 + webui/backend/session_manager.py | 176 - webui/backend/shared.py | 25 - webui/backend/tests/conftest.py | 22 + .../integration/test_chat_integration.py | 59 + webui/backend/tests/test_chat.py | 2618 ++++++++ webui/backend/tests/test_config.py | 56 + webui/backend/tests/test_mapping.py | 74 + webui/backend/tests/test_mcp_env.py | 40 + webui/backend/tests/test_model_link.py | 461 ++ webui/backend/tests/test_providers.py | 25 + .../tests/test_sessions_reconstruct.py | 144 + webui/backend/tests/test_skill_notice.py | 179 + webui/backend/tests/test_titler.py | 37 + webui/backend/tests/test_workspace.py | 239 + webui/backend/uv.lock | 3071 ++++++++++ webui/backend/websocket_handler.py | 495 -- webui/frontend/app/app.css | 225 + webui/frontend/app/assets/files/default.svg | 1 + webui/frontend/app/assets/files/excel.svg | 1 + webui/frontend/app/assets/files/java.svg | 1 + webui/frontend/app/assets/files/js.svg | 1 + webui/frontend/app/assets/files/md.svg | 1 + webui/frontend/app/assets/files/mp3.svg | 1 + webui/frontend/app/assets/files/pdf.svg | 1 + webui/frontend/app/assets/files/ppt.svg | 1 + webui/frontend/app/assets/files/py.svg | 1 + webui/frontend/app/assets/files/txt.svg | 1 + webui/frontend/app/assets/files/web.svg | 1 + webui/frontend/app/assets/files/word.svg | 1 + webui/frontend/app/assets/files/zip.svg | 1 + webui/frontend/app/assets/icons/add.svg | 1 + .../frontend/app/assets/icons/appearance.svg | 1 + .../frontend/app/assets/icons/arrow-down.svg | 1 + webui/frontend/app/assets/icons/audio.svg | 1 + webui/frontend/app/assets/icons/authorize.svg | 1 + webui/frontend/app/assets/icons/back.svg | 1 + .../app/assets/icons/chevron-down.svg | 1 + webui/frontend/app/assets/icons/close.svg | 1 + webui/frontend/app/assets/icons/copy.svg | 1 + .../app/assets/icons/custom-instruction.svg | 1 + webui/frontend/app/assets/icons/delete.svg | 1 + webui/frontend/app/assets/icons/download.svg | 1 + webui/frontend/app/assets/icons/edit.svg | 1 + webui/frontend/app/assets/icons/expand.svg | 1 + webui/frontend/app/assets/icons/folder.svg | 1 + .../frontend/app/assets/icons/generating.svg | 1 + webui/frontend/app/assets/icons/image.svg | 1 + webui/frontend/app/assets/icons/invoke.svg | 1 + webui/frontend/app/assets/icons/jump.svg | 32 + .../frontend/app/assets/icons/load-skill.svg | 1 + .../frontend/app/assets/icons/mcp-select.svg | 1 + .../app/assets/icons/mcp-skill-manage.svg | 1 + webui/frontend/app/assets/icons/mcp.svg | 1 + webui/frontend/app/assets/icons/media.svg | 1 + webui/frontend/app/assets/icons/memory.svg | 1 + .../app/assets/icons/model-settings.svg | 1 + .../frontend/app/assets/icons/more-chats.svg | 1 + webui/frontend/app/assets/icons/more.svg | 1 + webui/frontend/app/assets/icons/new-chat.svg | 25 + .../frontend/app/assets/icons/new-project.svg | 1 + webui/frontend/app/assets/icons/params.svg | 1 + .../frontend/app/assets/icons/personalize.svg | 1 + .../app/assets/icons/recent-chats.svg | 1 + webui/frontend/app/assets/icons/refresh.svg | 1 + webui/frontend/app/assets/icons/search.svg | 1 + webui/frontend/app/assets/icons/send.svg | 1 + webui/frontend/app/assets/icons/settings.svg | 1 + .../app/assets/icons/sidebar-toggle.svg | 1 + webui/frontend/app/assets/icons/skill.svg | 1 + webui/frontend/app/assets/icons/task-done.svg | 1 + .../frontend/app/assets/icons/task-paused.svg | 1 + .../app/assets/icons/task-running.svg | 15 + .../app/assets/icons/task-waiting.svg | 1 + webui/frontend/app/assets/icons/task.svg | 43 + webui/frontend/app/assets/icons/terminal.svg | 1 + webui/frontend/app/assets/icons/thinking.svg | 1 + webui/frontend/app/assets/icons/todo.svg | 1 + webui/frontend/app/assets/icons/upload.svg | 1 + webui/frontend/app/assets/icons/video.svg | 1 + webui/frontend/app/assets/icons/view.svg | 1 + webui/frontend/app/assets/icons/workspace.svg | 1 + .../app/assets/images/appearance-dark.png | Bin 0 -> 42098 bytes .../app/assets/images/appearance-light.png | Bin 0 -> 38162 bytes .../frontend/app/assets/images/empty-dark.png | Bin 0 -> 46090 bytes .../app/assets/images/empty-light.png | Bin 0 -> 55356 bytes .../app/assets/images/home-bg-dark.png | Bin 0 -> 394854 bytes .../app/assets/images/home-bg-light.png | Bin 0 -> 343894 bytes webui/frontend/app/assets/images/logo.png | Bin 0 -> 16734 bytes .../app/components/chat/ChatBackdrop.tsx | 29 + .../app/components/chat/ChatPanel.tsx | 913 +++ .../frontend/app/components/chat/ChatView.tsx | 432 ++ .../components/common/CardSkeletonGrid.tsx | 28 + .../app/components/common/CodeEditor.tsx | 158 + .../app/components/common/Composer.css | 153 + .../app/components/common/Composer.tsx | 1108 ++++ .../components/common/DeferredSkeleton.tsx | 32 + .../app/components/common/EmptyState.tsx | 60 + .../app/components/common/FileCard.tsx | 428 ++ .../app/components/common/FolderTree.css | 32 + .../app/components/common/FolderTree.tsx | 689 +++ .../app/components/common/IconButton.tsx | 71 + .../app/components/common/Markdown.css | 27 + .../app/components/common/Markdown.tsx | 139 + .../app/components/common/McpSelector.tsx | 96 + .../app/components/common/ModelSelector.css | 10 + .../app/components/common/ModelSelector.tsx | 166 + .../app/components/common/MsaButton.tsx | 64 + .../app/components/common/MsaSwitch.tsx | 37 + .../app/components/common/MsaTextArea.tsx | 74 + .../components/common/NProgressHandler.css | 32 + .../components/common/NProgressHandler.tsx | 32 + .../app/components/common/PillButton.tsx | 89 + .../app/components/common/SkillSelector.tsx | 95 + .../app/components/common/StableSender.tsx | 52 + .../app/components/layout/Sidebar.tsx | 760 +++ .../app/components/messages/ArtifactFiles.tsx | 67 + .../components/messages/AssistantMessage.tsx | 244 + .../app/components/messages/ErrorCard.tsx | 45 + .../app/components/messages/InlineCode.tsx | 11 + .../app/components/messages/MessageList.css | 12 + .../app/components/messages/MessageList.tsx | 236 + .../messages/MessageListSkeleton.tsx | 63 + .../app/components/messages/StepCard.tsx | 367 ++ .../components/messages/StepDetailRail.tsx | 408 ++ .../app/components/messages/TaskPlan.tsx | 125 + .../app/components/messages/ThoughtsFlow.tsx | 118 + .../app/components/messages/ToolBatch.tsx | 78 + .../app/components/messages/TurnPlan.tsx | 70 + .../app/components/messages/TurnProcess.tsx | 117 + .../app/components/messages/UserBubble.tsx | 95 + .../app/components/messages/searchResults.ts | 52 + .../messages/steps/ArtifactStepCard.tsx | 50 + .../messages/steps/AuthConfirmStepCard.tsx | 185 + .../messages/steps/TerminalStepCard.tsx | 129 + .../messages/steps/ToolCallStepCard.tsx | 221 + .../app/components/messages/turnSplit.ts | 39 + .../frontend/app/components/messages/types.ts | 20 + .../components/models/AddProviderModal.tsx | 199 + .../app/components/models/ModelEditModal.tsx | 219 + .../app/components/project/McpTabPanel.tsx | 189 + .../components/project/NewProjectModal.tsx | 349 ++ .../project/ProjectOverviewView.css | 12 + .../project/ProjectOverviewView.tsx | 794 +++ .../components/project/ProjectWidgetRail.tsx | 17 + .../app/components/project/SkillTabPanel.tsx | 152 + .../app/components/resources/McpCard.tsx | 110 + .../components/resources/McpCustomModal.tsx | 208 + .../app/components/resources/McpJsonView.tsx | 82 + .../app/components/resources/McpsPanel.tsx | 135 + .../app/components/resources/SkillCard.tsx | 91 + .../resources/SkillDetailDrawer.tsx | 252 + .../resources/SkillsFromLocalModal.tsx | 264 + .../app/components/resources/SkillsPanel.tsx | 209 + .../app/components/resources/mcpJson.ts | 113 + .../components/session/SessionRightRail.tsx | 911 +++ .../components/widgets/InstructionsCard.tsx | 41 + .../app/components/widgets/MemoryCard.tsx | 218 + .../app/components/widgets/WidgetCard.tsx | 67 + webui/frontend/app/entry.server.tsx | 96 + webui/frontend/app/global.d.ts | 5 + webui/frontend/app/layouts/app.tsx | 86 + webui/frontend/app/layouts/settings.tsx | 105 + webui/frontend/app/lib/agentProvider.ts | 622 ++ webui/frontend/app/lib/api.ts | 514 ++ webui/frontend/app/lib/designTokens.ts | 398 ++ webui/frontend/app/lib/download.ts | 119 + webui/frontend/app/lib/events.ts | 100 + webui/frontend/app/lib/i18n.tsx | 78 + webui/frontend/app/lib/lastAppRoute.ts | 30 + webui/frontend/app/lib/locales/en.json | 353 ++ webui/frontend/app/lib/locales/zh.json | 353 ++ webui/frontend/app/lib/msaTheme.ts | 138 + webui/frontend/app/lib/pageTitle.ts | 32 + webui/frontend/app/lib/presenceContext.tsx | 100 + webui/frontend/app/lib/theme.tsx | 82 + webui/frontend/app/lib/types.ts | 208 + webui/frontend/app/lib/useHydrated.ts | 24 + webui/frontend/app/lib/useMatchMedia.ts | 32 + webui/frontend/app/lib/useUrlPath.ts | 36 + webui/frontend/app/lib/workspaceFiles.tsx | 86 + webui/frontend/app/root.tsx | 168 + webui/frontend/app/routes.ts | 33 + webui/frontend/app/routes/catch-all.tsx | 14 + webui/frontend/app/routes/home.tsx | 15 + webui/frontend/app/routes/project-detail.tsx | 54 + .../app/routes/project-new-session.tsx | 23 + webui/frontend/app/routes/project-session.tsx | 69 + .../app/routes/settings/appearance.tsx | 96 + webui/frontend/app/routes/settings/index.tsx | 5 + .../app/routes/settings/mcp-skills.css | 43 + .../app/routes/settings/mcp-skills.tsx | 150 + webui/frontend/app/routes/settings/models.tsx | 376 ++ .../app/routes/settings/personalization.tsx | 166 + webui/frontend/index.html | 31 - webui/frontend/package-lock.json | 3960 ------------ webui/frontend/package.json | 53 +- webui/frontend/pnpm-lock.yaml | 5309 +++++++++++++++++ webui/frontend/public/favicon.ico | Bin 0 -> 370070 bytes webui/frontend/public/favicon.svg | 10 - webui/frontend/react-router.config.ts | 5 + webui/frontend/src/App.tsx | 63 - webui/frontend/src/components/ChatView.tsx | 565 -- .../src/components/ConversationView.tsx | 2365 -------- .../frontend/src/components/FileProgress.tsx | 58 - webui/frontend/src/components/Layout.tsx | 259 - webui/frontend/src/components/LogViewer.tsx | 238 - .../src/components/MessageContent.tsx | 190 - webui/frontend/src/components/SearchView.tsx | 450 -- .../src/components/SettingsDialog.tsx | 855 --- .../src/components/WorkflowProgress.tsx | 86 - .../deep_research/DeepResearchView.tsx | 1720 ------ webui/frontend/src/context/SessionContext.tsx | 576 -- webui/frontend/src/context/ThemeContext.tsx | 281 - webui/frontend/src/main.tsx | 27 - webui/frontend/tsconfig.json | 38 +- webui/frontend/tsconfig.node.json | 11 - webui/frontend/vite.config.ts | 53 +- webui/scripts/start-webui.ps1 | 8 - 293 files changed, 40101 insertions(+), 16542 deletions(-) create mode 100644 webui/backend/.env.example create mode 100644 webui/backend/.python-version delete mode 100644 webui/backend/agent_runner.py delete mode 100644 webui/backend/api.py create mode 100644 webui/backend/app/__init__.py create mode 100644 webui/backend/app/api/__init__.py create mode 100644 webui/backend/app/api/agent_settings.py create mode 100644 webui/backend/app/api/chat.py create mode 100644 webui/backend/app/api/instructions.py create mode 100644 webui/backend/app/api/mcps.py create mode 100644 webui/backend/app/api/memory.py create mode 100644 webui/backend/app/api/models.py create mode 100644 webui/backend/app/api/presence.py create mode 100644 webui/backend/app/api/profile.py create mode 100644 webui/backend/app/api/projects.py create mode 100644 webui/backend/app/api/providers.py create mode 100644 webui/backend/app/api/sessions.py create mode 100644 webui/backend/app/api/skills.py create mode 100644 webui/backend/app/api/workspace.py create mode 100644 webui/backend/app/backends/__init__.py create mode 100644 webui/backend/app/backends/errors.py create mode 100644 webui/backend/app/backends/mock.py create mode 100644 webui/backend/app/backends/ms_agent/__init__.py create mode 100644 webui/backend/app/backends/ms_agent/agent_settings.py create mode 100644 webui/backend/app/backends/ms_agent/backend.py create mode 100644 webui/backend/app/backends/ms_agent/bootstrap.py create mode 100644 webui/backend/app/backends/ms_agent/chat.py create mode 100644 webui/backend/app/backends/ms_agent/common.py create mode 100644 webui/backend/app/backends/ms_agent/config.py create mode 100644 webui/backend/app/backends/ms_agent/instructions.py create mode 100644 webui/backend/app/backends/ms_agent/mapping.py create mode 100644 webui/backend/app/backends/ms_agent/mcp_health.py create mode 100644 webui/backend/app/backends/ms_agent/mcps.py create mode 100644 webui/backend/app/backends/ms_agent/memory.py create mode 100644 webui/backend/app/backends/ms_agent/model_link.py create mode 100644 webui/backend/app/backends/ms_agent/models.py create mode 100644 webui/backend/app/backends/ms_agent/profile.py create mode 100644 webui/backend/app/backends/ms_agent/projects.py create mode 100644 webui/backend/app/backends/ms_agent/providers.py create mode 100644 webui/backend/app/backends/ms_agent/runtime.py create mode 100644 webui/backend/app/backends/ms_agent/sessions.py create mode 100644 webui/backend/app/backends/ms_agent/settings_store.py create mode 100644 webui/backend/app/backends/ms_agent/sidecar.py create mode 100644 webui/backend/app/backends/ms_agent/skill_notice.py create mode 100644 webui/backend/app/backends/ms_agent/skills.py create mode 100644 webui/backend/app/backends/ms_agent/titler.py create mode 100644 webui/backend/app/backends/ms_agent/workspace.py create mode 100644 webui/backend/app/core/__init__.py create mode 100644 webui/backend/app/core/envelope.py create mode 100644 webui/backend/app/core/filetypes.py create mode 100644 webui/backend/app/core/model_discovery.py create mode 100644 webui/backend/app/core/settings.py create mode 100644 webui/backend/app/core/store.py create mode 100644 webui/backend/app/main.py create mode 100644 webui/backend/app/schemas/__init__.py create mode 100644 webui/backend/app/schemas/agent_settings.py create mode 100644 webui/backend/app/schemas/chat.py create mode 100644 webui/backend/app/schemas/instruction.py create mode 100644 webui/backend/app/schemas/mcp.py create mode 100644 webui/backend/app/schemas/memory.py create mode 100644 webui/backend/app/schemas/model.py create mode 100644 webui/backend/app/schemas/profile.py create mode 100644 webui/backend/app/schemas/project.py create mode 100644 webui/backend/app/schemas/provider.py create mode 100644 webui/backend/app/schemas/session.py create mode 100644 webui/backend/app/schemas/skill.py create mode 100644 webui/backend/app/schemas/workspace.py delete mode 100644 webui/backend/config_manager.py delete mode 100644 webui/backend/deep_research_eventizer.py delete mode 100644 webui/backend/deep_research_worker.py delete mode 100644 webui/backend/deep_research_worker_manager.py delete mode 100644 webui/backend/main.py delete mode 100644 webui/backend/project_discovery.py create mode 100644 webui/backend/pyproject.toml delete mode 100644 webui/backend/session_manager.py delete mode 100644 webui/backend/shared.py create mode 100644 webui/backend/tests/conftest.py create mode 100644 webui/backend/tests/integration/test_chat_integration.py create mode 100644 webui/backend/tests/test_chat.py create mode 100644 webui/backend/tests/test_config.py create mode 100644 webui/backend/tests/test_mapping.py create mode 100644 webui/backend/tests/test_mcp_env.py create mode 100644 webui/backend/tests/test_model_link.py create mode 100644 webui/backend/tests/test_providers.py create mode 100644 webui/backend/tests/test_sessions_reconstruct.py create mode 100644 webui/backend/tests/test_skill_notice.py create mode 100644 webui/backend/tests/test_titler.py create mode 100644 webui/backend/tests/test_workspace.py create mode 100644 webui/backend/uv.lock delete mode 100644 webui/backend/websocket_handler.py create mode 100644 webui/frontend/app/app.css create mode 100644 webui/frontend/app/assets/files/default.svg create mode 100644 webui/frontend/app/assets/files/excel.svg create mode 100644 webui/frontend/app/assets/files/java.svg create mode 100644 webui/frontend/app/assets/files/js.svg create mode 100644 webui/frontend/app/assets/files/md.svg create mode 100644 webui/frontend/app/assets/files/mp3.svg create mode 100644 webui/frontend/app/assets/files/pdf.svg create mode 100644 webui/frontend/app/assets/files/ppt.svg create mode 100644 webui/frontend/app/assets/files/py.svg create mode 100644 webui/frontend/app/assets/files/txt.svg create mode 100644 webui/frontend/app/assets/files/web.svg create mode 100644 webui/frontend/app/assets/files/word.svg create mode 100644 webui/frontend/app/assets/files/zip.svg create mode 100644 webui/frontend/app/assets/icons/add.svg create mode 100644 webui/frontend/app/assets/icons/appearance.svg create mode 100644 webui/frontend/app/assets/icons/arrow-down.svg create mode 100644 webui/frontend/app/assets/icons/audio.svg create mode 100644 webui/frontend/app/assets/icons/authorize.svg create mode 100644 webui/frontend/app/assets/icons/back.svg create mode 100644 webui/frontend/app/assets/icons/chevron-down.svg create mode 100644 webui/frontend/app/assets/icons/close.svg create mode 100644 webui/frontend/app/assets/icons/copy.svg create mode 100644 webui/frontend/app/assets/icons/custom-instruction.svg create mode 100644 webui/frontend/app/assets/icons/delete.svg create mode 100644 webui/frontend/app/assets/icons/download.svg create mode 100644 webui/frontend/app/assets/icons/edit.svg create mode 100644 webui/frontend/app/assets/icons/expand.svg create mode 100644 webui/frontend/app/assets/icons/folder.svg create mode 100644 webui/frontend/app/assets/icons/generating.svg create mode 100644 webui/frontend/app/assets/icons/image.svg create mode 100644 webui/frontend/app/assets/icons/invoke.svg create mode 100644 webui/frontend/app/assets/icons/jump.svg create mode 100644 webui/frontend/app/assets/icons/load-skill.svg create mode 100644 webui/frontend/app/assets/icons/mcp-select.svg create mode 100644 webui/frontend/app/assets/icons/mcp-skill-manage.svg create mode 100644 webui/frontend/app/assets/icons/mcp.svg create mode 100644 webui/frontend/app/assets/icons/media.svg create mode 100644 webui/frontend/app/assets/icons/memory.svg create mode 100644 webui/frontend/app/assets/icons/model-settings.svg create mode 100644 webui/frontend/app/assets/icons/more-chats.svg create mode 100644 webui/frontend/app/assets/icons/more.svg create mode 100644 webui/frontend/app/assets/icons/new-chat.svg create mode 100644 webui/frontend/app/assets/icons/new-project.svg create mode 100644 webui/frontend/app/assets/icons/params.svg create mode 100644 webui/frontend/app/assets/icons/personalize.svg create mode 100644 webui/frontend/app/assets/icons/recent-chats.svg create mode 100644 webui/frontend/app/assets/icons/refresh.svg create mode 100644 webui/frontend/app/assets/icons/search.svg create mode 100644 webui/frontend/app/assets/icons/send.svg create mode 100644 webui/frontend/app/assets/icons/settings.svg create mode 100644 webui/frontend/app/assets/icons/sidebar-toggle.svg create mode 100644 webui/frontend/app/assets/icons/skill.svg create mode 100644 webui/frontend/app/assets/icons/task-done.svg create mode 100644 webui/frontend/app/assets/icons/task-paused.svg create mode 100644 webui/frontend/app/assets/icons/task-running.svg create mode 100644 webui/frontend/app/assets/icons/task-waiting.svg create mode 100644 webui/frontend/app/assets/icons/task.svg create mode 100644 webui/frontend/app/assets/icons/terminal.svg create mode 100644 webui/frontend/app/assets/icons/thinking.svg create mode 100644 webui/frontend/app/assets/icons/todo.svg create mode 100644 webui/frontend/app/assets/icons/upload.svg create mode 100644 webui/frontend/app/assets/icons/video.svg create mode 100644 webui/frontend/app/assets/icons/view.svg create mode 100644 webui/frontend/app/assets/icons/workspace.svg create mode 100644 webui/frontend/app/assets/images/appearance-dark.png create mode 100644 webui/frontend/app/assets/images/appearance-light.png create mode 100644 webui/frontend/app/assets/images/empty-dark.png create mode 100644 webui/frontend/app/assets/images/empty-light.png create mode 100644 webui/frontend/app/assets/images/home-bg-dark.png create mode 100644 webui/frontend/app/assets/images/home-bg-light.png create mode 100644 webui/frontend/app/assets/images/logo.png create mode 100644 webui/frontend/app/components/chat/ChatBackdrop.tsx create mode 100644 webui/frontend/app/components/chat/ChatPanel.tsx create mode 100644 webui/frontend/app/components/chat/ChatView.tsx create mode 100644 webui/frontend/app/components/common/CardSkeletonGrid.tsx create mode 100644 webui/frontend/app/components/common/CodeEditor.tsx create mode 100644 webui/frontend/app/components/common/Composer.css create mode 100644 webui/frontend/app/components/common/Composer.tsx create mode 100644 webui/frontend/app/components/common/DeferredSkeleton.tsx create mode 100644 webui/frontend/app/components/common/EmptyState.tsx create mode 100644 webui/frontend/app/components/common/FileCard.tsx create mode 100644 webui/frontend/app/components/common/FolderTree.css create mode 100644 webui/frontend/app/components/common/FolderTree.tsx create mode 100644 webui/frontend/app/components/common/IconButton.tsx create mode 100644 webui/frontend/app/components/common/Markdown.css create mode 100644 webui/frontend/app/components/common/Markdown.tsx create mode 100644 webui/frontend/app/components/common/McpSelector.tsx create mode 100644 webui/frontend/app/components/common/ModelSelector.css create mode 100644 webui/frontend/app/components/common/ModelSelector.tsx create mode 100644 webui/frontend/app/components/common/MsaButton.tsx create mode 100644 webui/frontend/app/components/common/MsaSwitch.tsx create mode 100644 webui/frontend/app/components/common/MsaTextArea.tsx create mode 100644 webui/frontend/app/components/common/NProgressHandler.css create mode 100644 webui/frontend/app/components/common/NProgressHandler.tsx create mode 100644 webui/frontend/app/components/common/PillButton.tsx create mode 100644 webui/frontend/app/components/common/SkillSelector.tsx create mode 100644 webui/frontend/app/components/common/StableSender.tsx create mode 100644 webui/frontend/app/components/layout/Sidebar.tsx create mode 100644 webui/frontend/app/components/messages/ArtifactFiles.tsx create mode 100644 webui/frontend/app/components/messages/AssistantMessage.tsx create mode 100644 webui/frontend/app/components/messages/ErrorCard.tsx create mode 100644 webui/frontend/app/components/messages/InlineCode.tsx create mode 100644 webui/frontend/app/components/messages/MessageList.css create mode 100644 webui/frontend/app/components/messages/MessageList.tsx create mode 100644 webui/frontend/app/components/messages/MessageListSkeleton.tsx create mode 100644 webui/frontend/app/components/messages/StepCard.tsx create mode 100644 webui/frontend/app/components/messages/StepDetailRail.tsx create mode 100644 webui/frontend/app/components/messages/TaskPlan.tsx create mode 100644 webui/frontend/app/components/messages/ThoughtsFlow.tsx create mode 100644 webui/frontend/app/components/messages/ToolBatch.tsx create mode 100644 webui/frontend/app/components/messages/TurnPlan.tsx create mode 100644 webui/frontend/app/components/messages/TurnProcess.tsx create mode 100644 webui/frontend/app/components/messages/UserBubble.tsx create mode 100644 webui/frontend/app/components/messages/searchResults.ts create mode 100644 webui/frontend/app/components/messages/steps/ArtifactStepCard.tsx create mode 100644 webui/frontend/app/components/messages/steps/AuthConfirmStepCard.tsx create mode 100644 webui/frontend/app/components/messages/steps/TerminalStepCard.tsx create mode 100644 webui/frontend/app/components/messages/steps/ToolCallStepCard.tsx create mode 100644 webui/frontend/app/components/messages/turnSplit.ts create mode 100644 webui/frontend/app/components/messages/types.ts create mode 100644 webui/frontend/app/components/models/AddProviderModal.tsx create mode 100644 webui/frontend/app/components/models/ModelEditModal.tsx create mode 100644 webui/frontend/app/components/project/McpTabPanel.tsx create mode 100644 webui/frontend/app/components/project/NewProjectModal.tsx create mode 100644 webui/frontend/app/components/project/ProjectOverviewView.css create mode 100644 webui/frontend/app/components/project/ProjectOverviewView.tsx create mode 100644 webui/frontend/app/components/project/ProjectWidgetRail.tsx create mode 100644 webui/frontend/app/components/project/SkillTabPanel.tsx create mode 100644 webui/frontend/app/components/resources/McpCard.tsx create mode 100644 webui/frontend/app/components/resources/McpCustomModal.tsx create mode 100644 webui/frontend/app/components/resources/McpJsonView.tsx create mode 100644 webui/frontend/app/components/resources/McpsPanel.tsx create mode 100644 webui/frontend/app/components/resources/SkillCard.tsx create mode 100644 webui/frontend/app/components/resources/SkillDetailDrawer.tsx create mode 100644 webui/frontend/app/components/resources/SkillsFromLocalModal.tsx create mode 100644 webui/frontend/app/components/resources/SkillsPanel.tsx create mode 100644 webui/frontend/app/components/resources/mcpJson.ts create mode 100644 webui/frontend/app/components/session/SessionRightRail.tsx create mode 100644 webui/frontend/app/components/widgets/InstructionsCard.tsx create mode 100644 webui/frontend/app/components/widgets/MemoryCard.tsx create mode 100644 webui/frontend/app/components/widgets/WidgetCard.tsx create mode 100644 webui/frontend/app/entry.server.tsx create mode 100644 webui/frontend/app/global.d.ts create mode 100644 webui/frontend/app/layouts/app.tsx create mode 100644 webui/frontend/app/layouts/settings.tsx create mode 100644 webui/frontend/app/lib/agentProvider.ts create mode 100644 webui/frontend/app/lib/api.ts create mode 100644 webui/frontend/app/lib/designTokens.ts create mode 100644 webui/frontend/app/lib/download.ts create mode 100644 webui/frontend/app/lib/events.ts create mode 100644 webui/frontend/app/lib/i18n.tsx create mode 100644 webui/frontend/app/lib/lastAppRoute.ts create mode 100644 webui/frontend/app/lib/locales/en.json create mode 100644 webui/frontend/app/lib/locales/zh.json create mode 100644 webui/frontend/app/lib/msaTheme.ts create mode 100644 webui/frontend/app/lib/pageTitle.ts create mode 100644 webui/frontend/app/lib/presenceContext.tsx create mode 100644 webui/frontend/app/lib/theme.tsx create mode 100644 webui/frontend/app/lib/types.ts create mode 100644 webui/frontend/app/lib/useHydrated.ts create mode 100644 webui/frontend/app/lib/useMatchMedia.ts create mode 100644 webui/frontend/app/lib/useUrlPath.ts create mode 100644 webui/frontend/app/lib/workspaceFiles.tsx create mode 100644 webui/frontend/app/root.tsx create mode 100644 webui/frontend/app/routes.ts create mode 100644 webui/frontend/app/routes/catch-all.tsx create mode 100644 webui/frontend/app/routes/home.tsx create mode 100644 webui/frontend/app/routes/project-detail.tsx create mode 100644 webui/frontend/app/routes/project-new-session.tsx create mode 100644 webui/frontend/app/routes/project-session.tsx create mode 100644 webui/frontend/app/routes/settings/appearance.tsx create mode 100644 webui/frontend/app/routes/settings/index.tsx create mode 100644 webui/frontend/app/routes/settings/mcp-skills.css create mode 100644 webui/frontend/app/routes/settings/mcp-skills.tsx create mode 100644 webui/frontend/app/routes/settings/models.tsx create mode 100644 webui/frontend/app/routes/settings/personalization.tsx delete mode 100644 webui/frontend/index.html delete mode 100644 webui/frontend/package-lock.json create mode 100644 webui/frontend/pnpm-lock.yaml create mode 100644 webui/frontend/public/favicon.ico delete mode 100644 webui/frontend/public/favicon.svg create mode 100644 webui/frontend/react-router.config.ts delete mode 100644 webui/frontend/src/App.tsx delete mode 100644 webui/frontend/src/components/ChatView.tsx delete mode 100644 webui/frontend/src/components/ConversationView.tsx delete mode 100644 webui/frontend/src/components/FileProgress.tsx delete mode 100644 webui/frontend/src/components/Layout.tsx delete mode 100644 webui/frontend/src/components/LogViewer.tsx delete mode 100644 webui/frontend/src/components/MessageContent.tsx delete mode 100644 webui/frontend/src/components/SearchView.tsx delete mode 100644 webui/frontend/src/components/SettingsDialog.tsx delete mode 100644 webui/frontend/src/components/WorkflowProgress.tsx delete mode 100644 webui/frontend/src/components/deep_research/DeepResearchView.tsx delete mode 100644 webui/frontend/src/context/SessionContext.tsx delete mode 100644 webui/frontend/src/context/ThemeContext.tsx delete mode 100644 webui/frontend/src/main.tsx delete mode 100644 webui/frontend/tsconfig.node.json delete mode 100644 webui/scripts/start-webui.ps1 diff --git a/.gitignore b/.gitignore index 534cc302a..611f30b8b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ eggs/ .eggs/ lib/ lib64/ +# The WebUI uses `app/lib` for checked-in TypeScript application code. +!webui/frontend/app/lib/ +!webui/frontend/app/lib/** parts/ sdist/ var/ @@ -58,6 +61,7 @@ nosetests.xml coverage.xml *.cover *node_modules* +.react-router/ .hypothesis/ .pytest_cache/ @@ -89,6 +93,9 @@ target/ # pyenv .python-version +# uv reads this to pin the WebUI backend to CPython 3.12. Without it uv only +# honours requires-python (">=3.12") and can build the venv on 3.13/3.14. +!webui/backend/.python-version # celery beat schedule file celerybeat-schedule @@ -171,3 +178,7 @@ webui/work_dir/ .ms_agent_snapshots/ .ms_agent/ + +# Chrome DevTools probes this exact path on every page load; ignore the +# file it asks for so a local 404-silencer never gets committed again. +webui/frontend/public/.well-known/appspecific/com.chrome.devtools.json diff --git a/webui/backend/.env.example b/webui/backend/.env.example new file mode 100644 index 000000000..d2a0cdf4d --- /dev/null +++ b/webui/backend/.env.example @@ -0,0 +1,32 @@ +# Server +HOST=127.0.0.1 +PORT=8000 + +# CORS — comma-separated origins allowed in dev +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +# Agent backend: +# mock — in-memory seed data, no SDK required (frontend-only dev) +# ms_agent — real ms-agent SDK (state persists under ~/.ms_agent) +AGENT_BACKEND=ms_agent + +# Reserved for future provider wiring +ANTHROPIC_API_KEY= + +# --- ms_agent backend LLM credentials (also used to bootstrap settings.json) --- +# Point at any OpenAI-compatible gateway (e.g. DashScope compatible-mode). +OPENAI_API_KEY= +OPENAI_BASE_URL= + +# Which provider/model the chat runtime uses when settings.json has no llm block. +# provider must be a known registry id: openai | modelscope | dashscope | anthropic | ... +MS_AGENT_LLM_PROVIDER=openai +MS_AGENT_LLM_MODEL=qwen3.7-plus + +# SDK home. Leave empty for the SDK default (~/.ms_agent, shared with the CLI/TUI), +# or set an isolated path (e.g. ~/.ms_agent_webui) so the WebUI never disrupts a +# TUI's config. When absent, the bootstrap seeds settings.json.llm from the vars above. +MS_AGENT_HOME=~/.ms_agent + +# Optional: third-party keys passed through to the SDK env (e.g. web-search MCP) +EXA_API_KEY= diff --git a/webui/backend/.python-version b/webui/backend/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/webui/backend/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/webui/backend/agent_runner.py b/webui/backend/agent_runner.py deleted file mode 100644 index 3f3879ac1..000000000 --- a/webui/backend/agent_runner.py +++ /dev/null @@ -1,1689 +0,0 @@ -# Copyright (c) Alibaba, Inc. and its affiliates. -""" -Agent runner for MS-Agent Web UI -Manages the execution of ms-agent through subprocess with log streaming. -""" -import asyncio -import os -import re -import signal -import subprocess -import sys -import yaml -from datetime import datetime -from typing import Any, Callable, Dict, Optional - - -class AgentRunner: - """Runs ms-agent as a subprocess with output streaming""" - - def __init__(self, - session_id: str, - project: Dict[str, Any], - config_manager, - on_output: Callable[[Dict[str, Any]], None] = None, - on_log: Callable[[Dict[str, Any]], None] = None, - on_progress: Callable[[Dict[str, Any]], None] = None, - on_complete: Callable[[Dict[str, Any]], None] = None, - on_error: Callable[[Dict[str, Any]], None] = None, - workflow_type: str = 'standard'): - self.session_id = session_id - self.project = project - self.config_manager = config_manager - self.on_output = on_output - self.on_log = on_log - self.on_progress = on_progress - self.on_complete = on_complete - self.on_error = on_error - self._workflow_type = workflow_type - - self.process: Optional[asyncio.subprocess.Process] = None - self.is_running = False - self._accumulated_output = '' - self._current_step = None - self._workflow_steps = [] - self._stop_requested = False - self._waiting_for_input = False # Track if agent is waiting for user input - self._waiting_input_sent = False # Track if waiting_input message was already sent - self._collecting_assistant_output = False # Track if we're collecting assistant output - self._collecting_tool_call = False # Track if we're collecting tool call info - self._collecting_tool_result = False # Track if we're collecting tool result - self._current_tool_name = None # Current tool being called - self._current_tool_args = None # Current tool arguments - self._current_tool_result = None # Current tool result - self._tool_call_json_buffer = '' # Buffer for collecting multi-line JSON tool call info - self._is_chat_mode = project.get( - 'id') == '__chat__' # Simple chat mode flag - self._chat_response_buffer = '' # Buffer for chat mode responses - - async def start(self, query: str): - """Start the agent""" - try: - self._stop_requested = False - self.is_running = True - - # Build command based on project type - cmd = self._build_command(query) - env = self._build_env() - - print('[Runner] Starting agent with command:') - print(f"[Runner] {' '.join(cmd)}") - print(f"[Runner] Working directory: {self.project['path']}") - - # Log the command - if self.on_log: - self.on_log({ - 'level': 'info', - 'message': f'Starting agent: {" ".join(cmd[:5])}...', - 'timestamp': datetime.now().isoformat() - }) - - # Start subprocess - self.process = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - stdin=asyncio.subprocess.PIPE, - env=env, - cwd=self.project['path'], - start_new_session=True) - - print(f'[Runner] Process started with PID: {self.process.pid}') - - # Start output reader - await self._read_output() - - except Exception as e: - print(f'[Runner] ERROR: {e}') - import traceback - traceback.print_exc() - if self.on_error: - self.on_error({'message': str(e), 'type': 'startup_error'}) - - async def stop(self): - """Stop the agent""" - self._stop_requested = True - self.is_running = False - if not self.process: - return - - try: - # If already exited, nothing to do - if self.process.returncode is not None: - return - - # Prefer terminating the whole process group to stop child processes too - try: - os.killpg(self.process.pid, signal.SIGTERM) - except Exception: - # Fallback to terminating only the parent - try: - self.process.terminate() - except Exception: - pass - - try: - await asyncio.wait_for(self.process.wait(), timeout=5.0) - except asyncio.TimeoutError: - try: - os.killpg(self.process.pid, signal.SIGKILL) - except Exception: - try: - self.process.kill() - except Exception: - pass - except Exception: - pass - - async def send_input(self, text: str): - """Send input to the agent""" - # Check if process is still alive and stdin is available - if not self.process: - print('[Runner] ERROR: Process is None, cannot send input') - if self.on_error: - self.on_error({ - 'message': - 'Agent process is not running. Please start a new conversation.', - 'type': 'input_error' - }) - return - - # Check if process has exited - if self.process.returncode is not None: - print( - f'[Runner] ERROR: Process has exited with code {self.process.returncode}, cannot send input' - ) - if self.on_error: - self.on_error({ - 'message': - 'Agent process has terminated. Please start a new conversation.', - 'type': 'input_error' - }) - return - - # Check if stdin is available - if not self.process.stdin: - print('[Runner] ERROR: Process stdin is None, cannot send input') - if self.on_error: - self.on_error({ - 'message': - 'Cannot send input: process stdin is not available.', - 'type': 'input_error' - }) - return - - print(f'[Runner] Sending input to agent: {text[:100]}...') - self._waiting_for_input = False # Reset waiting flag when sending input - self._waiting_input_sent = False # Reset so it can be sent again after next completion - self.is_running = True # Ensure process is marked as running - # Reset chat mode collection state for next response - self._collecting_assistant_output = False - self._chat_response_buffer = '' - - try: - self.process.stdin.write((text + '\n').encode()) - await self.process.stdin.drain() - print('[Runner] Input sent successfully') - except (BrokenPipeError, RuntimeError, OSError) as e: - print(f'[Runner] ERROR: Failed to send input: {e}') - if self.on_error: - self.on_error({ - 'message': - f'Failed to send input: Process may have terminated. Error: {str(e)}', - 'type': 'input_error' - }) - # Mark process as not running - self.is_running = False - self._waiting_for_input = False - - def _build_command(self, query: str) -> list: - """Build the command to run the agent""" - project_type = self.project.get('type') - project_path = self.project['path'] - config_file = self.project.get('config_file', '') - - # Get workflow_type from session if available - # This allows switching between standard and simple workflow for code_genesis - workflow_type = getattr(self, '_workflow_type', 'standard') - if workflow_type == 'simple' and project_type == 'workflow': - # For code_genesis with simple workflow, use simple_workflow.yaml - simple_config_file = os.path.join(project_path, - 'simple_workflow.yaml') - if os.path.exists(simple_config_file): - config_file = simple_config_file - - # Get python executable - python = sys.executable - - # Get MCP config file path - mcp_file = self.config_manager.get_mcp_file_path() - - if project_type == 'workflow' or project_type == 'agent': - # Use ms-agent CLI command (installed via entry point) - cmd = [ - 'ms-agent', 'run', '--config', config_file, - '--trust_remote_code', 'true' - ] - - if query: - cmd.extend(['--query', query]) - - if os.path.exists(mcp_file): - cmd.extend(['--mcp_server_file', mcp_file]) - - # Add LLM config from user settings - llm_config = self.config_manager.get_llm_config() - temperature_enabled = bool( - llm_config.get('temperature_enabled', False)) - if llm_config.get('api_key'): - provider = llm_config.get('provider', 'modelscope') - if provider == 'modelscope': - cmd.extend( - ['--llm.modelscope_api_key', llm_config['api_key']]) - # Set llm.service to modelscope to ensure the correct service is used - cmd.extend(['--llm.service', 'modelscope']) - # Pass base_url if set by user - if llm_config.get('base_url'): - cmd.extend([ - '--llm.modelscope_base_url', llm_config['base_url'] - ]) - # Pass model if set by user - if llm_config.get('model'): - cmd.extend(['--llm.model', llm_config['model']]) - # Pass temperature if set by user (in generation_config) - if temperature_enabled and llm_config.get( - 'temperature') is not None: - cmd.extend([ - '--generation_config.temperature', - str(llm_config['temperature']) - ]) - # Pass max_tokens if set by user (in generation_config) - if llm_config.get('max_tokens'): - cmd.extend([ - '--generation_config.max_tokens', - str(llm_config['max_tokens']) - ]) - elif provider == 'openai': - cmd.extend(['--llm.openai_api_key', llm_config['api_key']]) - # Set llm.service to openai to ensure the correct service is used - cmd.extend(['--llm.service', 'openai']) - # Pass base_url if set by user - if llm_config.get('base_url'): - cmd.extend( - ['--llm.openai_base_url', llm_config['base_url']]) - # Pass model if set by user - if llm_config.get('model'): - cmd.extend(['--llm.model', llm_config['model']]) - # Pass temperature if set by user (in generation_config) - if temperature_enabled and llm_config.get( - 'temperature') is not None: - cmd.extend([ - '--generation_config.temperature', - str(llm_config['temperature']) - ]) - # Pass max_tokens if set by user (in generation_config) - if llm_config.get('max_tokens'): - cmd.extend([ - '--generation_config.max_tokens', - str(llm_config['max_tokens']) - ]) - - # Add edit_file_config from user settings (skip for chat mode) - if self.project.get('id') != '__chat__': - edit_file_config = self.config_manager.get_edit_file_config() - if edit_file_config.get('api_key'): - # If API key is provided, pass edit_file_config - cmd.extend([ - '--tools.file_system.edit_file_config.api_key', - edit_file_config['api_key'] - ]) - if edit_file_config.get('base_url'): - cmd.extend([ - '--tools.file_system.edit_file_config.base_url', - edit_file_config['base_url'] - ]) - if edit_file_config.get('diff_model'): - cmd.extend([ - '--tools.file_system.edit_file_config.diff_model', - edit_file_config['diff_model'] - ]) - else: - # If no API key, exclude edit_file from tools - # Read the current include list from config file and remove edit_file - try: - with open(config_file, 'r', encoding='utf-8') as f: - config_data = yaml.safe_load(f) - if config_data and 'tools' in config_data and 'file_system' in config_data[ - 'tools']: - include_list = config_data['tools'][ - 'file_system'].get('include', []) - if isinstance( - include_list, - list) and 'edit_file' in include_list: - # Remove edit_file from the list - filtered_include = [ - tool for tool in include_list - if tool != 'edit_file' - ] - # Pass the filtered list as comma-separated string - cmd.extend([ - '--tools.file_system.include', - ','.join(filtered_include) - ]) - except Exception as e: - print( - f'[Runner] Warning: Could not read config file to exclude edit_file: {e}' - ) - # Fallback: explicitly exclude edit_file - cmd.extend( - ['--tools.file_system.exclude', 'edit_file']) - - # Add EdgeOne Pages API token and project name from user settings - edgeone_pages_config = self.config_manager.get_edgeone_pages_config( - ) - if edgeone_pages_config.get('api_token'): - # If API token is provided, pass it to the MCP server config - cmd.extend([ - '--tools.edgeone-pages-mcp.env.EDGEONE_PAGES_API_TOKEN', - edgeone_pages_config['api_token'] - ]) - if edgeone_pages_config.get('project_name'): - # If project name is provided, pass it to the MCP server config - cmd.extend([ - '--tools.edgeone-pages-mcp.env.EDGEONE_PAGES_PROJECT_NAME', - edgeone_pages_config['project_name'] - ]) - - elif project_type == 'script': - # Run the script directly - cmd = [python, self.project['config_file']] - else: - cmd = [python, '-m', 'ms_agent', 'run', '--config', project_path] - - return cmd - - def _build_env(self) -> Dict[str, str]: - """Build environment variables""" - env = os.environ.copy() - - # Add config env vars - env.update(self.config_manager.get_env_vars()) - - # Set PYTHONUNBUFFERED for real-time output - env['PYTHONUNBUFFERED'] = '1' - - return env - - async def _read_output(self): - """Read and process output from the subprocess""" - print('[Runner] Starting to read output...') - process_exited = False - empty_line_count = 0 # Track consecutive empty lines after process exit - try: - # Continue reading even after process exits to catch all remaining output - while (self.is_running or process_exited) and self.process: - # Check if process has exited - if self.process.returncode is not None and not process_exited: - process_exited = True - print( - f'[Runner] Process exited with code: {self.process.returncode}' - ) - # Continue reading remaining output even after process exits - # This ensures we don't miss any URLs or important messages - if not self.process.stdout: - # If stdout is closed, we can't read more - if self._waiting_for_input: - self._waiting_for_input = False - break - - # Check if stdout is still available - if not self.process.stdout: - print('[Runner] Process stdout is closed') - break - - try: - # Use shorter timeout after process exits to read remaining data faster - timeout = 0.1 if process_exited else 1.0 - line = await asyncio.wait_for( - self.process.stdout.readline(), timeout=timeout) - except asyncio.TimeoutError: - # Timeout - check if we're waiting for input - if self._waiting_for_input: - # Check if process is still alive - if self.process.returncode is None: - # Flush any pending chat response before waiting - if self._is_chat_mode: - self._flush_chat_response() - # Send waiting_input message to enable frontend input - if self.on_output and not self._waiting_input_sent: - self.on_output({ - 'type': 'waiting_input', - 'content': '', - 'role': 'system', - 'metadata': { - 'waiting': True - } - }) - self._waiting_input_sent = True - # Process is still alive, continue waiting - continue - else: - # Process exited, but continue reading remaining output - # Try a few more times before giving up - if empty_line_count < 3: - continue - break - # Not waiting for input, check if process is still alive - if self.process.returncode is not None: - # Process exited, try a few more times before giving up - if empty_line_count < 3: - continue - break - continue - - if not line: - # Empty line - check context - if process_exited: - # After process exit, count consecutive empty lines - empty_line_count += 1 - # If we get 3 consecutive empty lines/timeouts, assume no more data - if empty_line_count >= 3: - print('[Runner] No more output after process exit') - break - # Continue trying to read more - continue - - # Check if agent is waiting for input before breaking - if self._waiting_for_input: - # Check if process is still alive - if self.process.returncode is None: - # Flush any pending chat response before waiting - if self._is_chat_mode: - self._flush_chat_response() - # Send waiting_input message to enable frontend input - if self.on_output and not self._waiting_input_sent: - self.on_output({ - 'type': 'waiting_input', - 'content': '', - 'role': 'system', - 'metadata': { - 'waiting': True - } - }) - self._waiting_input_sent = True - print( - '[Runner] Agent is waiting for user input, keeping process alive...' - ) - # Keep process alive and wait for input - await asyncio.sleep( - 0.5) # Small delay to avoid busy waiting - continue - else: - print( - '[Runner] Process exited while waiting for input' - ) - # Process exited, but continue reading any remaining output - # Don't break yet - there might be more data in stdout buffer - process_exited = True - continue - print('[Runner] No more output, breaking...') - break - - # Reset empty line count when we get actual data - empty_line_count = 0 - text = line.decode('utf-8', errors='replace').rstrip() - print(f'[Runner] Output: {text[:200]}' - if len(text) > 200 else f'[Runner] Output: {text}') - try: - await self._process_line(text) - except Exception as e: - print(f'[Runner] ERROR processing line: {e}') - import traceback - traceback.print_exc() - - # Wait for process to complete and handle completion - if self.process: - # Get return code if not already available - if self.process.returncode is None: - return_code = await self.process.wait() - else: - return_code = self.process.returncode - - print(f'[Runner] Process exited with code: {return_code}') - - # Flush chat response for chat mode - self._flush_chat_response() - - # Flush any accumulated assistant output before handling completion - if self._collecting_assistant_output and self._accumulated_output.strip( - ): - cleaned = re.sub(r'\[INFO:ms_agent\]\s*', '', - self._accumulated_output.strip()) - cleaned = re.sub(r'\[([^\]]+)\]\s*', '', cleaned, count=1) - print( - f'[Runner] Flushing accumulated output on process exit: {cleaned[:200]}...' - ) - if cleaned and self.on_output: - self.on_output({ - 'type': 'agent_output', - 'content': cleaned, - 'role': 'assistant', - 'metadata': { - 'agent': self._current_step or 'agent' - } - }) - self._accumulated_output = '' - self._collecting_assistant_output = False - - # If stop was requested, do not report as completion/error - if self._stop_requested: - if self.on_log: - self.on_log({ - 'level': 'info', - 'message': 'Agent stopped by user', - 'timestamp': datetime.now().isoformat() - }) - return - - # Complete current step if any before handling exit - if self._current_step and self.on_output: - self.on_output({ - 'type': 'step_complete', - 'content': self._current_step, - 'role': 'assistant', - 'metadata': { - 'step': self._current_step, - 'status': 'completed' - } - }) - # If Refine step completes successfully, it should be waiting for input - if return_code == 0 and self._current_step.lower( - ) == 'refine': - self._waiting_for_input = True - self._current_step = None - - # If was waiting for input but process exited, clear waiting state - if self._waiting_for_input: - self._waiting_for_input = False - # If process completed successfully, send completion message - if return_code == 0: - # Send waiting_input message if not already sent - if self.on_output and not self._waiting_input_sent: - self.on_output({ - 'type': - 'waiting_input', - 'content': - ('✅ Initial refinement completed. ' - 'You can now provide additional feedback or modifications.' - ), - 'role': - 'system', - 'metadata': { - 'waiting': True - } - }) - self._waiting_input_sent = True - if self.on_complete: - self.on_complete({ - 'status': - 'success', - 'message': - 'Agent completed successfully' - }) - else: - if self.on_error: - self.on_error({ - 'message': - ('Agent process terminated while waiting for input. ' - f'Exit code: {return_code}'), - 'type': - 'process_exit_error', - 'code': - return_code - }) - elif return_code == 0: - if self.on_complete: - self.on_complete({ - 'status': - 'success', - 'message': - 'Agent completed successfully' - }) - else: - if self.on_error: - self.on_error({ - 'message': f'Agent exited with code {return_code}', - 'type': 'exit_error', - 'code': return_code - }) - - except Exception as e: - print(f'[Runner] Read error: {e}') - import traceback - traceback.print_exc() - if not self._stop_requested and self.on_error: - self.on_error({'message': str(e), 'type': 'read_error'}) - finally: - if not self._waiting_for_input: - self.is_running = False - print('[Runner] Finished reading output') - else: - print('[Runner] Process waiting for input, keeping alive...') - - @staticmethod - def _clean_log_prefix(text: str) -> str: - """Remove log prefixes like [INFO:ms_agent] [agent_name]""" - # Remove [INFO:ms_agent] prefix - text = re.sub(r'\[INFO:ms_agent\]\s*', '', text) - # Remove [agent_name] prefix (e.g., [orchestrator]) - text = re.sub(r'^\[([^\]]+)\]\s*', '', text) - return text.strip() - - async def _process_chat_line(self, line: str): - """Simple chat mode - handle assistant output, tool calls, and tool results""" - cleaned = self._clean_log_prefix(line) - - # Detect [tool_calling]: marker - flush assistant output and start collecting tool call - if '[tool_calling]:' in line: - self._flush_chat_response() - self._collecting_tool_call = True - self._tool_call_json_buffer = '' - return - - # Collect tool call JSON - if self._collecting_tool_call: - if cleaned: - if self._tool_call_json_buffer: - self._tool_call_json_buffer += '\n' + cleaned - else: - self._tool_call_json_buffer = cleaned - # Check if we have a complete JSON object - if cleaned == '}' and self._tool_call_json_buffer.strip( - ).startswith('{'): - self._flush_tool_call() - return - - # Detect tool execution result (success or error) - if 'execute tool call' in line: - if self.on_output: - is_error = 'error' in line.lower() - self.on_output({ - 'type': 'tool_result', - 'content': cleaned, - 'role': 'assistant', - 'metadata': { - 'is_error': is_error - } - }) - return - - # Detect [assistant]: marker - start collecting - if '[assistant]:' in line: - self._flush_chat_response() - self._collecting_assistant_output = True - self._chat_response_buffer = '' - return - - # Detect end markers - flush assistant output - end_markers = ['[user]:'] - for marker in end_markers: - if marker in line: - self._flush_chat_response() - return - - # If collecting assistant output, accumulate the content - if self._collecting_assistant_output: - if cleaned: - if self._chat_response_buffer: - self._chat_response_buffer += '\n' + cleaned - else: - self._chat_response_buffer = cleaned - # Mark as waiting for input - process is still running - self._waiting_for_input = True - - def _flush_tool_call(self): - """Send tool call information to frontend""" - if self._is_chat_mode and self._tool_call_json_buffer.strip( - ) and self.on_output: - try: - import json - tool_data = json.loads(self._tool_call_json_buffer) - tool_name = tool_data.get('tool_name', 'unknown') - print(f'[Runner] Tool call: {tool_name}') - self.on_output({ - 'type': 'tool_call', - 'content': '', - 'role': 'assistant', - 'metadata': { - 'tool_name': tool_name, - 'arguments': tool_data.get('arguments', {}), - 'id': tool_data.get('id', '') - } - }) - except json.JSONDecodeError: - print('[Runner] Failed to parse tool call JSON') - self._tool_call_json_buffer = '' - self._collecting_tool_call = False - - def _flush_chat_response(self): - """Send final chat response with done=True""" - if self._is_chat_mode and self._chat_response_buffer.strip( - ) and self.on_output: - print( - f'[Runner] Chat complete: {len(self._chat_response_buffer)} chars' - ) - self.on_output({ - 'type': 'stream', - 'content': self._chat_response_buffer.strip(), - 'role': 'assistant', - 'done': True - }) - self._chat_response_buffer = '' - # Don't reset _collecting_assistant_output here - more content may come - # It will be reset when we see [tool_calling]: or [user]: or process exits - - async def _process_line(self, line: str): - """Process a line of output""" - # Skip usage statistics lines - if '[usage]' in line or '[usage_total]' in line: - return - - # Simple chat mode: just capture assistant output - if self._is_chat_mode: - await self._process_chat_line(line) - return - - # Skip lines without agent name (generic system messages) - # Pattern: [INFO:ms_agent] without [agent_name] afterwards - if '[INFO:ms_agent]' in line: - # Check if there's an agent name tag [xxx] after [INFO:ms_agent] - import re - if not re.search(r'\[INFO:ms_agent\]\s*\[([^\]]+)\]', line): - return - - # Log the cleaned line - if self.on_log: - log_level = self._detect_log_level(line) - cleaned_message = self._clean_log_prefix(line) - await self.on_log({ - 'level': - log_level, - 'message': - cleaned_message if cleaned_message else line, - 'timestamp': - datetime.now().isoformat() - }) - - # Parse for special patterns (use original line for pattern matching) - await self._detect_patterns(line) - - def _detect_log_level(self, line: str) -> str: - """Detect log level from line""" - line_lower = line.lower() - if '[error' in line_lower or 'error:' in line_lower: - return 'error' - elif '[warn' in line_lower or 'warning:' in line_lower: - return 'warning' - elif '[debug' in line_lower: - return 'debug' - return 'info' - - def _scan_and_send_output_files(self, programmer_step=None): - """Read tasks.txt to get all generated files with their completion status""" - try: - project_path = self.project.get('path') - if not project_path: - return - - # tasks.txt path: projects/code_genesis/output/tasks.txt - tasks_file = os.path.join(project_path, 'output', 'tasks.txt') - - if not os.path.exists(tasks_file): - print(f'[Runner] tasks.txt not found: {tasks_file}') - return - - print(f'[Runner] Reading tasks.txt: {tasks_file}') - - # Read and parse tasks.txt - with open(tasks_file, 'r', encoding='utf-8') as f: - lines = f.readlines() - - generated_files = [] - - for line in lines: - line = line.strip() - # Skip header line and empty lines - if not line or line.startswith('Files in'): - continue - - # Parse format: "css/styles.css: ✅Built" - if ':' in line and '✅' in line: - file_path = line.split(':')[0].strip() - generated_files.append(file_path) - - print( - f'[Runner] Found {len(generated_files)} files in tasks.txt: {generated_files}' - ) - - # Send all files in one batch - if generated_files and self.on_output: - self.on_output({ - 'type': 'file_output', - 'content': generated_files, # Send as array - 'role': 'assistant', - 'metadata': { - 'files': generated_files, - 'source': 'tasks.txt' - } - }) - - except Exception as e: - print(f'[Runner] Error reading tasks.txt: {e}') - import traceback - traceback.print_exc() - - async def _detect_patterns(self, line: str): - """Detect special patterns in output""" - # IMPORTANT: Check for deployment URL FIRST, before any other patterns that might return early - # This ensures URLs are always detected even if other patterns match - url_match = None - # Pattern 1: "url": "https://..." - url_match = re.search(r'"url":\s*"(https?://[^"]+)"', line) - # Pattern 2: Direct URL like "https://mcp.edgeone.site/share/..." - if not url_match: - url_match = re.search(r'(https?://mcp\.edgeone\.site/[^\s]+)', - line) - # Pattern 3: EdgeOne Pages URL like "https://...edgeone.cool?..." - # BUT skip if this is a curl command line (testing command, not actual deployment URL) - if not url_match and 'curl -s' not in line and 'curl ' not in line: - url_match = re.search(r'(https?://[^\s]*edgeone\.cool[^\s]*)', - line) - # Pattern 4: Also check for edgeone.site URLs in any format (fallback) - # BUT skip if this is a curl command line - if not url_match and 'curl -s' not in line and 'curl ' not in line: - url_match = re.search(r'(https?://[^\s]*edgeone\.site[^\s]*)', - line) - if url_match: - deployment_url = url_match.group(1) - # Clean up escaped characters in URL (e.g., \& -> &) - deployment_url = deployment_url.replace('\\&', '&') - print( - f'[Runner] Detected deployment URL (early): {deployment_url} from line: {line[:100]}' - ) - if self.on_output: - self.on_output({ - 'type': 'deployment_url', - 'content': deployment_url, - 'role': 'assistant', - 'metadata': { - 'url': deployment_url - } - }) - # Continue processing - don't return yet, other patterns might also match - - # Detect OpenAI API errors and other API errors - # Check for OpenAI error patterns - if 'openai.' in line.lower() and ('error' in line.lower() - or 'Error' in line): - error_message = line.strip() - # Try to extract error details from the line - # Pattern: openai.NotFoundError: Error code: 404 - {'error': {'message': '...', ...}} - json_match = re.search(r'\{.*?\}', error_message, re.DOTALL) - if json_match: - try: - import json - error_data = json.loads(json_match.group(0)) - if 'error' in error_data and 'message' in error_data[ - 'error']: - error_msg = error_data['error']['message'] - error_type = error_data['error'].get( - 'type', 'API Error') - error_message = f'**{error_type}**: {error_msg}' - except Exception: - pass - - print(f'[Runner] Detected API error: {error_message}') - if self.on_error: - self.on_error({'message': error_message, 'type': 'api_error'}) - # Also send as output message so it appears in the conversation - if self.on_output: - self.on_output({ - 'type': 'error', - 'content': error_message, - 'role': 'system', - 'metadata': { - 'error_type': 'api_error' - } - }) - return - - # Detect other error patterns - error_patterns = [ - r'Error code:\s*(\d+)\s*-\s*({.*?})', - ] - - for pattern in error_patterns: - error_match = re.search(pattern, line, re.IGNORECASE | re.DOTALL) - if error_match: - error_message = line.strip() - # Try to extract JSON error details if available - json_match = re.search(r'\{.*?\}', error_message, re.DOTALL) - if json_match: - try: - import json - error_data = json.loads(json_match.group(0)) - if 'error' in error_data and 'message' in error_data[ - 'error']: - error_msg = error_data['error']['message'] - error_type = error_data['error'].get( - 'type', 'API Error') - error_message = f'**{error_type}**: {error_msg}' - except Exception: - pass - - print(f'[Runner] Detected API error: {error_message}') - if self.on_error: - self.on_error({ - 'message': - error_message, - 'type': - 'api_error', - 'code': - error_match.group(1) if error_match.groups() else None - }) - # Also send as output message so it appears in the conversation - if self.on_output: - self.on_output({ - 'type': 'error', - 'content': error_message, - 'role': 'system', - 'metadata': { - 'error_type': 'api_error' - } - }) - return - - # Detect workflow step beginning: "[tag] Agent tag task beginning." - begin_match = re.search( - r'\[([^\]]+)\]\s*Agent\s+\S+\s+task\s+beginning', line) - if begin_match: - step_name = begin_match.group(1) - - # Skip sub-steps and programmer agents (handled separately) - if (('-r' in step_name and '-' in step_name.split('-r')[-1]) - or step_name.startswith('programmer-')): - return - - print(f'[Runner] Step beginning: {step_name}') - - # Flush previous step if exists - if self._current_step and self._accumulated_output.strip(): - cleaned = re.sub(r'\[INFO:ms_agent\]\s*', '', - self._accumulated_output.strip()) - cleaned = re.sub(r'\[([^\]]+)\]\s*', '', cleaned, count=1) - if cleaned and self.on_output: - self.on_output({ - 'type': 'agent_output', - 'content': cleaned, - 'role': 'assistant', - 'metadata': { - 'agent': self._current_step - } - }) - self._accumulated_output = '' - self._collecting_assistant_output = False - - if self._current_step and self.on_output: - self.on_output({ - 'type': 'step_complete', - 'content': self._current_step, - 'role': 'assistant', - 'metadata': { - 'step': self._current_step, - 'status': 'completed' - } - }) - - # Start new step - self._current_step = step_name - if step_name not in self._workflow_steps: - self._workflow_steps.append(step_name) - - step_status = { - s: ('completed' if i < self._workflow_steps.index(step_name) - else 'running' if s == step_name else 'pending') - for i, s in enumerate(self._workflow_steps) - } - - if self.on_progress: - self.on_progress({ - 'type': 'workflow', - 'current_step': step_name, - 'steps': self._workflow_steps.copy(), - 'step_status': step_status - }) - - if self.on_output: - self.on_output({ - 'type': 'step_start', - 'content': step_name, - 'role': 'assistant', - 'metadata': { - 'step': step_name, - 'status': 'running' - } - }) - - # If Refine step is starting, scan tasks.txt for all generated files - # This ensures files are detected after Coding phase completes - if step_name.lower() == 'refine': - self._scan_and_send_output_files() - - return - - # Detect programmer-xxx pattern (first occurrence signals coding start) - programmer_match = re.search(r'\[programmer-([^\]]+)\]', line) - if programmer_match: - programmer_agent = f'programmer-{programmer_match.group(1)}' - - # If this is FIRST programmer agent, trigger coding step start - if not self._current_step or not self._current_step.startswith( - 'programmer-'): - print( - f'[Runner] First programmer agent detected: {programmer_agent} - starting coding step' - ) - - # Flush previous step's output - if self._current_step and self._accumulated_output.strip(): - cleaned = re.sub(r'\[INFO:ms_agent\]\s*', '', - self._accumulated_output.strip()) - cleaned = re.sub(r'\[([^\]]+)\]\s*', '', cleaned, count=1) - if cleaned and self.on_output: - self.on_output({ - 'type': 'agent_output', - 'content': cleaned, - 'role': 'assistant', - 'metadata': { - 'agent': self._current_step - } - }) - self._accumulated_output = '' - self._collecting_assistant_output = False - - # Mark previous step complete - if self._current_step and self.on_output: - self.on_output({ - 'type': 'step_complete', - 'content': self._current_step, - 'role': 'assistant', - 'metadata': { - 'step': self._current_step, - 'status': 'completed' - } - }) - - # Start coding step - self._current_step = programmer_agent - if 'coding' not in self._workflow_steps: - self._workflow_steps.append('coding') - - step_status = { - s: ('completed' if i < self._workflow_steps.index('coding') - else 'running' if s == 'coding' else 'pending') - for i, s in enumerate(self._workflow_steps) - } - - if self.on_progress: - self.on_progress({ - 'type': 'workflow', - 'current_step': 'coding', - 'steps': self._workflow_steps.copy(), - 'step_status': step_status - }) - - if self.on_output: - self.on_output({ - 'type': 'step_start', - 'content': 'coding', - 'role': 'assistant', - 'metadata': { - 'step': 'coding', - 'status': 'running' - } - }) - - # Update current programmer agent - elif programmer_agent != self._current_step: - self._current_step = programmer_agent - - # Helper to flush accumulated assistant output - def flush_accumulated_output(): - print(f'[Runner] flush_accumulated_output called: ' - f'collecting={self._collecting_assistant_output}, ' - f'buffer_len={len(self._accumulated_output)}') - print( - f'[Runner] Buffer content: {self._accumulated_output[:200]}...' - if len(self._accumulated_output) > 200 else - f'[Runner] Buffer content: {self._accumulated_output}') - if self._collecting_assistant_output and self._accumulated_output.strip( - ): - # Clean log prefixes - cleaned_content = re.sub(r'\[INFO:ms_agent\]\s*', '', - self._accumulated_output.strip()) - cleaned_content = re.sub( - r'\[([^\]]+)\]\s*', '', cleaned_content, count=1) - print( - f'[Runner] Flushing assistant output: {cleaned_content[:100]}...' - ) - - # Map agent name for display - agent_name = self._current_step or 'agent' - display_agent = agent_name - if agent_name.startswith('programmer-'): - display_agent = 'coding' - - if cleaned_content and self.on_output: - self.on_output({ - 'type': 'agent_output', - 'content': cleaned_content, - 'role': 'assistant', - 'metadata': { - 'agent': display_agent - } - }) - self._accumulated_output = '' - self._collecting_assistant_output = False - else: - print(f'[Runner] flush_accumulated_output skipped: ' - f'collecting={self._collecting_assistant_output}, ' - f'has_content={bool(self._accumulated_output.strip())}') - - # Detect workflow step finished: "[tag] Agent tag task finished." - end_match = re.search(r'\[([^\]]+)\]\s*Agent\s+\S+\s+task\s+finished', - line) - if end_match: - step_name = end_match.group(1) - - # Skip install (handled by programmer detection) and sub-steps - if step_name == 'install' or ('-r' in step_name and '-' - in step_name.split('-r')[-1]): - return - - # Skip flush for refine (already flushed during collection) - if step_name.lower() != 'refine': - flush_accumulated_output() - print(f'[Runner] Step finished: {step_name}') - - # If refine step finished, check if it's waiting for input - if step_name.lower() == 'refine': - # Check if there's a waiting input message in recent output - # The refine agent will log "Waiting for user feedback" when should_stop is True - # We'll detect this pattern and mark as waiting for input - # This will be detected by the "Initial refinement completed" pattern above - pass - - # Try to match step name - remove 'programmer-' prefix if needed - if step_name not in self._workflow_steps: - # Try removing 'programmer-' prefix to match actual step name - if step_name.startswith('programmer-'): - base_name = step_name.replace('programmer-', '', 1) - if base_name in self._workflow_steps: - step_name = base_name - else: - # Add the original step name if base name not found - self._workflow_steps.append(step_name) - else: - # Add step if not in list - self._workflow_steps.append(step_name) - - # Build step status dict - all steps up to current are completed - step_status = {} - for s in self._workflow_steps: - step_status[s] = 'completed' if self._workflow_steps.index( - s) <= self._workflow_steps.index(step_name) else 'pending' - - if self.on_progress: - self.on_progress({ - 'type': 'workflow', - 'current_step': step_name, - 'steps': self._workflow_steps.copy(), - 'step_status': step_status - }) - - # Send step complete message - if self.on_output: - self.on_output({ - 'type': 'step_complete', - 'content': step_name, - 'role': 'assistant', - 'metadata': { - 'step': step_name, - 'status': 'completed' - } - }) - - # Clear current step since it's completed - self._current_step = None - return - - # Clean log prefixes from line - # Detect assistant output: "[tag] [assistant]:" - if '[assistant]:' in line: - in_coding = self._current_step and self._current_step.startswith( - 'programmer-') - - if not in_coding: - # Start collecting (don't send first line immediately) - self._accumulated_output = '' - self._collecting_assistant_output = True - # Extract content after [assistant]: if any on same line - parts = line.split('[assistant]:', 1) - if len(parts) > 1 and parts[1].strip(): - content = self._clean_log_prefix(parts[1].strip()) - if content: - self._accumulated_output = content + '\n' - else: - # In coding phase: don't collect - self._collecting_assistant_output = False - self._accumulated_output = '' - # Don't return - continue to process line for file_output detection - - # Continue collecting assistant output - elif self._collecting_assistant_output: - # Skip if in coding phase - if self._current_step and self._current_step.startswith( - 'programmer-'): - self._collecting_assistant_output = False - self._accumulated_output = '' - # Don't return - continue processing - else: - # Check if new pattern starts - if '[tool_calling]:' in line or ('[assistant]:' in line - and 'Agent' not in line): - if self._accumulated_output.strip(): - cleaned = self._clean_log_prefix( - self._accumulated_output.strip()) - if cleaned and self.on_output: - self.on_output({ - 'type': 'agent_output', - 'content': cleaned, - 'role': 'assistant', - 'metadata': { - 'agent': self._current_step or 'agent' - } - }) - self._accumulated_output = '' - self._collecting_assistant_output = False - else: - # Accumulate line but also check for deployment URL and waiting_input - if line.strip(): - cleaned_line = self._clean_log_prefix(line) - if cleaned_line: - self._accumulated_output += cleaned_line + '\n' - # Check for EdgeOne deployment URL in this line - url_match = re.search( - r'(https?://[^\s]*edgeone\.cool[^\s]*)', - cleaned_line) - if url_match: - deployment_url = url_match.group(1) - # Clean up escaped characters in URL (e.g., \& -> &) - deployment_url = deployment_url.replace( - '\\&', '&') - print( - f'[Runner] Detected deployment URL in assistant: {deployment_url}' - ) - if self.on_output: - self.on_output({ - 'type': 'deployment_url', - 'content': deployment_url, - 'role': 'assistant', - 'metadata': { - 'url': deployment_url - } - }) - # Check for waiting for input pattern - if ('Waiting for user feedback' in line - or 'Waiting for user input from stdin' - in line): - print('[Runner] Agent waiting for user input') - self._waiting_for_input = True - if self.on_output and not self._waiting_input_sent: - self.on_output({ - 'type': - 'waiting_input', - 'content': - ('✅ Initial refinement completed. ' - 'You can now provide additional feedback or modifications.' - ), - 'role': - 'system', - 'metadata': { - 'waiting': True - } - }) - self._waiting_input_sent = True - return - - # Detect tool calls: "[tag] [tool_calling]:" - if '[tool_calling]:' in line: - self._collecting_tool_call = True - self._current_tool_name = None - self._current_tool_args = None - self._tool_call_json_buffer = '' - - # Check if JSON starts on the same line after [tool_calling]: - parts = line.split('[tool_calling]:', 1) - if len(parts) > 1: - json_part = parts[1].strip() - if json_part.startswith('{'): - self._tool_call_json_buffer = json_part - elif json_part: - # Try to extract tool name directly if it's not JSON format - tool_match = re.search(r'([\w\-]+(?:---[\w\-]+)?)', - json_part) - if tool_match: - self._current_tool_name = tool_match.group(1) - return - - # Continue collecting tool call info - if self._collecting_tool_call: - # Extract agent name from line if available (for better matching) - agent_name_from_line = None - if '[INFO:ms_agent]' in line: - agent_match = re.search(r'\[INFO:ms_agent\]\s*\[([^\]]+)\]', - line) - if agent_match: - agent_name_from_line = agent_match.group(1) - - # Clean log prefixes from line before processing - cleaned_line = self._clean_log_prefix(line) - - # Accumulate JSON lines - if cleaned_line.strip(): - # Remove agent tag prefix if present (e.g., [programmer-config.json]) - cleaned_line = re.sub(r'^\[[^\]]+\]\s*', '', cleaned_line) - - # Skip truncation marker lines (just "...") - if cleaned_line.strip() == '...': - return - - # Skip lines that are just a trailing backslash (truncated escape sequence) - if cleaned_line.strip() == '\\': - return - - if self._tool_call_json_buffer: - self._tool_call_json_buffer += cleaned_line # Don't add newline, keep JSON compact - elif cleaned_line.strip().startswith('{'): - self._tool_call_json_buffer = cleaned_line.strip() - else: - self._tool_call_json_buffer += cleaned_line - - # Only try to parse when buffer contains tool_name and ends with } - if (self._tool_call_json_buffer - and '"tool_name"' in self._tool_call_json_buffer - and self._tool_call_json_buffer.strip().endswith('}')): - try: - import json - tool_info = json.loads(self._tool_call_json_buffer) - print('[Runner] Parsed tool JSON successfully') - tool_name = tool_info.get('tool_name') or tool_info.get( - 'name', 'unknown') - tool_args = tool_info.get('arguments', {}) - print(f'[Runner] Extracted tool_name: {tool_name}') - if tool_name and tool_name != 'unknown': - self._current_tool_name = tool_name - self._current_tool_args = tool_args - agent_name = agent_name_from_line or self._current_step or 'agent' - print( - f'[Runner] Sending tool call: {tool_name}, agent: {agent_name}' - ) - if self.on_output: - self.on_output({ - 'type': 'tool_call', - 'content': f'调用工具: {tool_name}', - 'role': 'assistant', - 'metadata': { - 'tool_name': tool_name, - 'tool_args': tool_args, - 'agent': agent_name - } - }) - # Clear buffer but KEEP collecting - there may be more tool calls - self._tool_call_json_buffer = '' - # Don't return or stop collecting - next line might be another tool call JSON - else: - print( - f'[Runner] WARNING: Invalid tool_name: {tool_name}' - ) - except json.JSONDecodeError as e: - # JSON not complete yet, keep collecting - # Only log if we have tool_name - helps debug parsing issues - if '"tool_name"' in self._tool_call_json_buffer: - print( - f'[Runner] JSON incomplete, continuing... (error: {str(e)[:50]})' - ) - except Exception as e: - print(f'[Runner] Error parsing tool JSON: {e}') - - # Check if we hit a new pattern, stop collecting - if '[assistant]:' in line or 'Agent' in line and 'task' in line or '[tool_result]:' in line: - # If we have partial data, try to send it - if self._tool_call_json_buffer: - tool_name_match = re.search(r'"tool_name"\s*:\s*"([^"]+)"', - self._tool_call_json_buffer) - if tool_name_match: - tool_name = tool_name_match.group(1) - # Try to extract arguments - handle nested JSON objects - args_start = self._tool_call_json_buffer.find( - '"arguments"') - tool_args = {} - if args_start != -1: - brace_start = self._tool_call_json_buffer.find( - '{', args_start) - if brace_start != -1: - brace_count = 0 - brace_end = brace_start - for i in range( - brace_start, - len(self._tool_call_json_buffer)): - if self._tool_call_json_buffer[i] == '{': - brace_count += 1 - elif self._tool_call_json_buffer[i] == '}': - brace_count -= 1 - if brace_count == 0: - brace_end = i + 1 - break - - if brace_end > brace_start: - args_str = self._tool_call_json_buffer[ - brace_start:brace_end] - try: - tool_args = json.loads(args_str) - except Exception: - pass - - # Determine agent name - prefer extracted from line, then current step - agent_name = agent_name_from_line or self._current_step or 'agent' - print( - f'[Runner] Sending tool call (pattern end): ' - f'{tool_name}, agent: {agent_name}, args: {tool_args}' - ) - if self.on_output: - self.on_output({ - 'type': 'tool_call', - 'content': f'调用工具: {tool_name}', - 'role': 'assistant', - 'metadata': { - 'tool_name': tool_name, - 'tool_args': tool_args, - 'agent': agent_name - } - }) - self._collecting_tool_call = False - self._tool_call_json_buffer = '' - return - - # Detect tool results: "[tag] [tool_result]:" - if '[tool_result]:' in line: - self._collecting_tool_result = True - # Extract result content - parts = line.split('[tool_result]:', 1) - if len(parts) > 1: - result_content = parts[1].strip() - if result_content: - self._current_tool_result = result_content - # Send tool result immediately if we have tool name - if self._current_tool_name and self.on_output: - self.on_output({ - 'type': 'tool_result', - 'content': f'工具 {self._current_tool_name} 执行完成', - 'role': 'assistant', - 'metadata': { - 'tool_name': self._current_tool_name, - 'tool_result': result_content, - 'agent': self._current_step or 'agent' - } - }) - # Reset tool info - self._current_tool_name = None - self._current_tool_result = None - self._collecting_tool_result = False - return - - # Continue collecting tool result - if self._collecting_tool_result: - # Accumulate result content - if line.strip() and not line.strip().startswith('['): - if self._current_tool_result: - self._current_tool_result += '\n' + line - else: - self._current_tool_result = line - - # Check for EdgeOne deployment URL in tool result - # Pattern 1: JSON format with edgeone.cool or edgeone.site - url_match = re.search( - r'"url":\s*"(https?://[^"]+edgeone\.(cool|site)[^"]+)"', - line) - # Pattern 2: Direct URL with edgeone.cool or edgeone.site - if not url_match: - url_match = re.search( - r'(https?://[^\s]*edgeone\.(cool|site)[^\s]*)', line) - if url_match: - deployment_url = url_match.group(1) - # Clean up escaped characters in URL (e.g., \& -> &) - deployment_url = deployment_url.replace('\\&', '&') - print( - f'[Runner] Detected deployment URL in tool result: {deployment_url}' - ) - if self.on_output: - self.on_output({ - 'type': 'deployment_url', - 'content': deployment_url, - 'role': 'assistant', - 'metadata': { - 'url': deployment_url - } - }) - # After deployment success, prompt user for further input - self._waiting_for_input = True - if not self._waiting_input_sent: - self.on_output({ - 'type': 'waiting_input', - 'content': - 'You can now provide additional feedback or visit the deployed site.', - 'role': 'system', - 'metadata': { - 'waiting': True, - 'deployment_complete': True - } - }) - self._waiting_input_sent = True - - # Send result if we have tool name and accumulated enough content - if self._current_tool_name and len( - self._current_tool_result) > 100 and self.on_output: - self.on_output({ - 'type': 'tool_result', - 'content': f'工具 {self._current_tool_name} 执行完成', - 'role': 'assistant', - 'metadata': { - 'tool_name': self._current_tool_name, - 'tool_result': self._current_tool_result, - 'agent': self._current_step or 'agent' - } - }) - # Reset - self._current_tool_name = None - self._current_tool_result = None - self._collecting_tool_result = False - elif '[assistant]:' in line or '[tool_calling]:' in line or 'Agent' in line and 'task' in line: - # Hit a new pattern, send accumulated result - if self._current_tool_name and self._current_tool_result and self.on_output: - self.on_output({ - 'type': 'tool_result', - 'content': f'工具 {self._current_tool_name} 执行完成', - 'role': 'assistant', - 'metadata': { - 'tool_name': self._current_tool_name, - 'tool_result': self._current_tool_result, - 'agent': self._current_step or 'agent' - } - }) - self._current_tool_name = None - self._current_tool_result = None - self._collecting_tool_result = False - return - - # Detect file writing - file_match = re.search(r'writing file:?\s*["\']?([^\s"\']+)["\']?', - line.lower()) - if not file_match: - file_match = re.search( - r'creating file:?\s*["\']?([^\s"\']+)["\']?', line.lower()) - if file_match and self.on_progress: - filename = file_match.group(1) - self.on_progress({ - 'type': 'file', - 'file': filename, - 'status': 'writing' - }) - return - - # Detect file written/created/saved - multiple patterns - file_keywords = [ - 'file created', 'file written', 'file saved', 'saved to:', - 'wrote to', 'generated:', 'output:' - ] - if any(keyword in line.lower() for keyword in file_keywords): - # Try to extract filename with extension - # More strict pattern: must have a proper filename with extension, not just numbers - file_match = re.search( - r'["\']?([a-zA-Z0-9_\-][^\s"\'\/\[\]]*\.[a-zA-Z0-9]+)["\']?', - line) - if file_match and self.on_progress: - filename = file_match.group(1) - # Validate filename: must not be just numbers or version numbers like "0.0" - if filename and not re.match(r'^\d+\.\d+$', - filename) and len(filename) > 2: - # Strip 'programmer-' prefix from filename - if filename.startswith('programmer-'): - filename = filename[len('programmer-'):] - print(f'[Runner] Detected file output: {filename}') - # Only send progress update (file_output will be sent from tasks.txt) - self.on_progress({ - 'type': 'file', - 'file': filename, - 'status': 'completed' - }) - return - - # Detect output file paths (e.g., "output/user_story.txt" standalone) - output_path_match = re.search( - r'(?:^|\s)((?:output|projects)/[^\s]+\.[a-zA-Z0-9]+)(?:\s|$)', - line) - if output_path_match and self.on_progress: - filename = output_path_match.group(1) - # Strip 'programmer-' prefix from basename only (not from path) - # Split path and filename - if '/' in filename: - parts = filename.rsplit('/', 1) - if len(parts) == 2 and parts[1].startswith('programmer-'): - parts[1] = parts[1][len('programmer-'):] - filename = '/'.join(parts) - elif filename.startswith('programmer-'): - filename = filename[len('programmer-'):] - print(f'[Runner] Detected output path: {filename}') - # Only send progress update (file_output will be sent from tasks.txt) - self.on_progress({ - 'type': 'file', - 'file': filename, - 'status': 'completed' - }) - return - - # Deployment URL detection moved to the beginning of _detect_patterns - # to ensure it's always checked before any early returns - - # Detect agent waiting for user input - # Pattern: "✅ Initial refinement completed. You can now provide..." - # Also detect: "Agent completed initial refinement. Waiting for user feedback." - # Also detect: "Waiting for user input from stdin..." - if ('Initial refinement completed' in line - or 'provide additional feedback' in line - or 'Waiting for user feedback' in line - or 'Agent completed initial refinement' in line - or 'Waiting for user input from stdin' in line): - print('[Runner] Agent waiting for user input') - self._waiting_for_input = True # Mark that agent is waiting for input - if self.on_output and not self._waiting_input_sent: - self.on_output({ - 'type': 'waiting_input', - 'content': - '✅ Initial refinement completed. You can now provide additional feedback or modifications.', - 'role': 'system', - 'metadata': { - 'waiting': True - } - }) - self._waiting_input_sent = True - return diff --git a/webui/backend/api.py b/webui/backend/api.py deleted file mode 100644 index fa68b849b..000000000 --- a/webui/backend/api.py +++ /dev/null @@ -1,806 +0,0 @@ -# Copyright (c) Alibaba, Inc. and its affiliates. -""" -API endpoints for the MS-Agent Web UI -""" -import mimetypes -import os -from fastapi import APIRouter, HTTPException, Query -from fastapi.responses import FileResponse -from pathlib import Path -from pydantic import BaseModel, Field -# Import shared instances -from shared import config_manager, project_discovery, session_manager -from typing import Any, Dict, List, Optional - -router = APIRouter() - - -def get_backend_root() -> Path: - return Path(__file__).resolve().parents[ - 1] # equal to dirname(dirname(__file__)) - - -def get_session_root(session_id: str) -> Path: - if not session_id or not str(session_id).strip(): - raise HTTPException(status_code=400, detail='session_id is required') - - backend_root = get_backend_root() - work_dir = (backend_root / 'work_dir' / str(session_id)).resolve() - work_dir.mkdir(parents=True, exist_ok=True) - return work_dir - - -# Request/Response Models -class ProjectInfo(BaseModel): - id: str - name: str - display_name: str - description: str - type: str # 'workflow' or 'agent' - path: str - has_readme: bool - supports_workflow_switch: bool = False - - -class SessionCreate(BaseModel): - project_id: Optional[str] = None # Optional for chat mode - query: Optional[str] = None - workflow_type: Optional[ - str] = 'standard' # 'standard' or 'simple' for code_genesis - session_type: Optional[str] = 'project' # 'project' or 'chat' - - -class SessionInfo(BaseModel): - id: str - project_id: str - project_name: str - status: str - created_at: str - session_type: Optional[str] = 'project' # 'project' or 'chat' - - -class LLMConfig(BaseModel): - provider: str = 'openai' - model: str = 'qwen3-coder-plus' - api_key: Optional[str] = None - base_url: Optional[str] = None - temperature: Optional[float] = None - temperature_enabled: Optional[bool] = False - max_tokens: Optional[int] = None - - -class EditFileConfig(BaseModel): - api_key: Optional[str] = None - base_url: str = 'https://api.morphllm.com/v1' - diff_model: str = 'morph-v3-fast' - - -class EdgeOnePagesConfig(BaseModel): - api_token: Optional[str] = None - project_name: Optional[str] = None - - -class SearchKeysConfig(BaseModel): - exa_api_key: Optional[str] = None - serpapi_api_key: Optional[str] = None - - -class DeepResearchAgentConfig(BaseModel): - model: Optional[str] = '' - api_key: Optional[str] = '' - base_url: Optional[str] = '' - - -class DeepResearchSearchConfig(BaseModel): - summarizer_model: Optional[str] = '' - summarizer_api_key: Optional[str] = '' - summarizer_base_url: Optional[str] = '' - - -class DeepResearchConfig(BaseModel): - researcher: DeepResearchAgentConfig = Field( - default_factory=DeepResearchAgentConfig) - searcher: DeepResearchAgentConfig = Field( - default_factory=DeepResearchAgentConfig) - reporter: DeepResearchAgentConfig = Field( - default_factory=DeepResearchAgentConfig) - search: DeepResearchSearchConfig = Field( - default_factory=DeepResearchSearchConfig) - - -class MCPServer(BaseModel): - name: str - type: str # 'stdio' or 'sse' - command: Optional[str] = None - args: Optional[List[str]] = None - url: Optional[str] = None - env: Optional[Dict[str, str]] = None - - -class GlobalConfig(BaseModel): - llm: LLMConfig - mcp_servers: Dict[str, Any] - theme: str = 'dark' - output_dir: str = './output' - - -# Project Endpoints -@router.get('/projects', response_model=List[ProjectInfo]) -async def list_projects(): - """List all available projects""" - print( - f'project_discovery.discover_projects(): {project_discovery.discover_projects()}' - ) - return project_discovery.discover_projects() - - -@router.get('/projects/{project_id}') -async def get_project(project_id: str): - """Get detailed information about a specific project""" - project = project_discovery.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail='Project not found') - return project - - -@router.get('/projects/{project_id}/readme') -async def get_project_readme(project_id: str): - """Get the README content for a project""" - readme = project_discovery.get_project_readme(project_id) - if readme is None: - raise HTTPException(status_code=404, detail='README not found') - return {'content': readme} - - -@router.get('/projects/{project_id}/workflow') -async def get_project_workflow(project_id: str, - session_id: Optional[str] = None): - """Get the workflow configuration for a project - - If session_id is provided, returns the workflow based on the session's workflow_type. - For code_genesis project, 'simple' workflow_type will return simple_workflow.yaml. - """ - project = project_discovery.get_project(project_id) - if not project: - raise HTTPException(status_code=404, detail='Project not found') - - # Determine workflow_type from session if session_id is provided - workflow_type = 'standard' # default - if session_id: - session = session_manager.get_session(session_id) - if session and session.get('workflow_type'): - workflow_type = session['workflow_type'] - - # Determine which workflow file to use - if workflow_type == 'simple' and project.get('supports_workflow_switch'): - # For simple workflow, try simple_workflow.yaml first - workflow_file = os.path.join(project['path'], 'simple_workflow.yaml') - if not os.path.exists(workflow_file): - # Fallback to standard workflow.yaml if simple_workflow.yaml doesn't exist - workflow_file = os.path.join(project['path'], 'workflow.yaml') - else: - # Standard workflow - workflow_file = os.path.join(project['path'], 'workflow.yaml') - - if not os.path.exists(workflow_file): - raise HTTPException(status_code=404, detail='Workflow file not found') - - try: - import yaml - with open(workflow_file, 'r', encoding='utf-8') as f: - workflow_data = yaml.safe_load(f) - return {'workflow': workflow_data, 'workflow_type': workflow_type} - except Exception as e: - raise HTTPException( - status_code=500, detail=f'Error reading workflow file: {str(e)}') - - -# Session Endpoints -@router.post('/sessions', response_model=SessionInfo) -async def create_session(session_data: SessionCreate): - """Create a new session for a project or chat mode""" - # Check if this is a chat mode session - if session_data.session_type == 'chat': - # Create chat session without requiring a project - session = session_manager.create_session( - project_id='__chat__', - project_name='Chat Assistant', - workflow_type='standard', - session_type='chat') - return session - - # For project mode, validate project exists - project = project_discovery.get_project(session_data.project_id) - if not project: - raise HTTPException(status_code=404, detail='Project not found') - - # Validate workflow_type for projects that support switching - workflow_type = session_data.workflow_type or 'standard' - if project.get('supports_workflow_switch'): - if workflow_type not in ['standard', 'simple']: - raise HTTPException( - status_code=400, - detail="workflow_type must be 'standard' or 'simple'") - - session = session_manager.create_session( - project_id=session_data.project_id, - project_name=project['name'], - workflow_type=workflow_type, - session_type='project') - return session - - -@router.get('/sessions', response_model=List[SessionInfo]) -async def list_sessions(): - """List all active sessions""" - return session_manager.list_sessions() - - -@router.get('/sessions/{session_id}') -async def get_session(session_id: str): - """Get session details""" - session = session_manager.get_session(session_id) - if not session: - raise HTTPException(status_code=404, detail='Session not found') - return session - - -@router.delete('/sessions/{session_id}') -async def delete_session(session_id: str): - """Delete a session""" - success = session_manager.delete_session(session_id) - if not success: - raise HTTPException(status_code=404, detail='Session not found') - return {'status': 'deleted'} - - -@router.get('/sessions/{session_id}/messages') -async def get_session_messages(session_id: str): - """Get all messages for a session""" - messages = session_manager.get_messages(session_id) - if messages is None: - raise HTTPException(status_code=404, detail='Session not found') - return {'messages': messages} - - -@router.get('/sessions/{session_id}/dr_events') -async def get_session_dr_events(session_id: str, - after_id: Optional[int] = Query(None, ge=0)): - """Get deep research event history for a session.""" - events = session_manager.list_dr_events(session_id, after_id) - if events is None: - raise HTTPException(status_code=404, detail='Session not found') - return {'events': events} - - -# Configuration Endpoints -@router.get('/config') -async def get_config(): - """Get global configuration""" - return config_manager.get_config() - - -@router.put('/config') -async def update_config(config: GlobalConfig): - """Update global configuration""" - config_manager.update_config(config.model_dump()) - return {'status': 'updated'} - - -@router.get('/config/llm') -async def get_llm_config(): - """Get LLM configuration""" - return config_manager.get_llm_config() - - -@router.put('/config/llm') -async def update_llm_config(config: LLMConfig): - """Update LLM configuration""" - config_manager.update_llm_config(config.model_dump()) - return {'status': 'updated'} - - -@router.get('/config/mcp') -async def get_mcp_config(): - """Get MCP servers configuration""" - return config_manager.get_mcp_config() - - -@router.put('/config/mcp') -async def update_mcp_config(servers: Dict[str, Any]): - """Update MCP servers configuration""" - config_manager.update_mcp_config(servers) - return {'status': 'updated'} - - -@router.get('/config/edit_file') -async def get_edit_file_config(): - """Get edit_file_config configuration""" - return config_manager.get_edit_file_config() - - -@router.put('/config/edit_file') -async def update_edit_file_config(config: EditFileConfig): - """Update edit_file_config configuration""" - config_manager.update_edit_file_config(config.model_dump()) - return {'status': 'updated'} - - -@router.get('/config/edgeone_pages') -async def get_edgeone_pages_config(): - """Get EdgeOne Pages configuration""" - return config_manager.get_edgeone_pages_config() - - -@router.put('/config/edgeone_pages') -async def update_edgeone_pages_config(config: EdgeOnePagesConfig): - """Update EdgeOne Pages configuration""" - config_manager.update_edgeone_pages_config(config.model_dump()) - return {'status': 'updated'} - - -@router.get('/config/search_keys') -async def get_search_keys_config(): - """Get search API keys configuration""" - return config_manager.get_search_keys() - - -@router.put('/config/search_keys') -async def update_search_keys_config(config: SearchKeysConfig): - """Update search API keys configuration""" - config_manager.update_search_keys(config.model_dump()) - return {'status': 'updated'} - - -@router.get('/config/deep_research') -async def get_deep_research_config(): - """Get deep research configuration""" - return config_manager.get_deep_research_config() - - -@router.put('/config/deep_research') -async def update_deep_research_config(config: DeepResearchConfig): - """Update deep research configuration""" - config_manager.update_deep_research_config(config.model_dump()) - return {'status': 'updated'} - - -@router.post('/config/mcp/servers') -async def add_mcp_server(server: MCPServer): - """Add a new MCP server""" - config_manager.add_mcp_server(server.name, - server.model_dump(exclude={'name'})) - return {'status': 'added'} - - -@router.delete('/config/mcp/servers/{server_name}') -async def remove_mcp_server(server_name: str): - """Remove an MCP server""" - success = config_manager.remove_mcp_server(server_name) - if not success: - raise HTTPException(status_code=404, detail='Server not found') - return {'status': 'removed'} - - -# Available models endpoint -@router.get('/models') -async def list_available_models(): - """List available LLM models""" - return { - 'models': [ - { - 'provider': 'modelscope', - 'model': 'Qwen/Qwen3-235B-A22B-Instruct-2507', - 'display_name': 'Qwen3-235B (Recommended)' - }, - { - 'provider': 'modelscope', - 'model': 'Qwen/Qwen2.5-72B-Instruct', - 'display_name': 'Qwen2.5-72B' - }, - { - 'provider': 'modelscope', - 'model': 'Qwen/Qwen2.5-32B-Instruct', - 'display_name': 'Qwen2.5-32B' - }, - { - 'provider': 'modelscope', - 'model': 'deepseek-ai/DeepSeek-V3', - 'display_name': 'DeepSeek-V3' - }, - { - 'provider': 'openai', - 'model': 'gpt-4o', - 'display_name': 'GPT-4o' - }, - { - 'provider': 'openai', - 'model': 'gpt-4o-mini', - 'display_name': 'GPT-4o Mini' - }, - { - 'provider': 'anthropic', - 'model': 'claude-3-5-sonnet-20241022', - 'display_name': 'Claude 3.5 Sonnet' - }, - ] - } - - -# File content endpoint -class FileReadRequest(BaseModel): - path: str - session_id: Optional[str] = None - root_dir: Optional[str] = None - - -@router.get('/files/list') -async def list_output_files( - output_dir: Optional[str] = Query(default='output'), - session_id: Optional[str] = Query(default=None), - root_dir: Optional[str] = Query(default=None), -): - """List all files under root_dir as a tree structure. - root_dir: optional. If not provided, defaults to ms-agent/output. - Also supports 'projects' or 'projects/xxx' etc. - """ - # Excluded folders - exclude_dirs = { - 'node_modules', '__pycache__', '.git', '.venv', 'venv', 'dist', 'build' - } - - # Base directories (same way as read_file_content) - base_dir = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - projects_dir = os.path.join(base_dir, 'projects') - - if session_id: - - session_root = get_session_root(session_id) - resolved_root = (session_root / '').resolve() - - elif not root_dir or root_dir.strip() == '': - resolved_root = output_dir - else: - root_dir = root_dir.strip() - - # If absolute, use as-is - if os.path.isabs(root_dir): - resolved_root = root_dir - # If starts with 'projects/', join with base_dir - elif root_dir.startswith('projects/'): - resolved_root = os.path.join(base_dir, root_dir) - else: - # Try relative to output first, then projects - cand1 = os.path.join(output_dir, root_dir) - cand2 = os.path.join(projects_dir, root_dir) - - if os.path.exists(cand1): - resolved_root = cand1 - elif os.path.exists(cand2): - resolved_root = cand2 - else: - # If user passes "output" or "projects" explicitly - if root_dir in ('output', 'output/'): - resolved_root = output_dir - elif root_dir in ('projects', 'projects/'): - resolved_root = projects_dir - else: - # fall back to output + root_dir (but it likely doesn't exist) - resolved_root = cand1 - - resolved_root = os.path.normpath(os.path.abspath(resolved_root)) - - # Warning: Web UI is for local-only convenience (frontend/backend assumed localhost). - # For production, enforce strict backend file-access validation and authorization - # to prevent arbitrary path read/write (e.g., path traversal). - # TODO: Security check: ensure `resolved_root` is within configured allowed roots. - - def build_tree(dir_path: str) -> dict: - result = {'folders': {}, 'files': []} - - if not os.path.exists(dir_path): - return result - - try: - items = os.listdir(dir_path) - except PermissionError: - return result - - for item in sorted(items): - if item.startswith('.') or item in exclude_dirs: - continue - - full_path = os.path.join(dir_path, item) - - if os.path.isdir(full_path): - subtree = build_tree(full_path) - if subtree['folders'] or subtree['files']: - result['folders'][item] = subtree - else: - # Return RELATIVE path to resolved_root (better for frontend + read API) - rel_path = os.path.relpath(full_path, resolved_root) - - result['files'].append({ - 'name': item, - 'path': rel_path, # <-- relative path - 'abs_path': - full_path, # optional: if you still want absolute for debugging - 'size': os.path.getsize(full_path), - 'modified': os.path.getmtime(full_path) - }) - - result['files'].sort(key=lambda x: x['modified'], reverse=True) - return result - - print('resolved_root =', resolved_root) - tree = build_tree(resolved_root) - return {'tree': tree, 'root_dir': resolved_root} - - -def get_allowed_roots(): - base_dir = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - output_dir = os.path.join(base_dir, 'output') - projects_dir = os.path.join(base_dir, 'projects') - return base_dir, os.path.normpath(output_dir), os.path.normpath( - projects_dir) - - -def resolve_root_dir(root_dir: Optional[str]) -> str: - """ - Resolve optional root_dir to an absolute normalized path within allowed roots. - Default: output_dir - Supports: - - None/"" => output_dir - - "output", "projects", "projects/xxx" - - absolute path (must still be under allowed roots) - """ - _, output_dir, projects_dir = get_allowed_roots() - - if not root_dir or root_dir.strip() == '': - resolved = output_dir - else: - rd = root_dir.strip() - - if os.path.isabs(rd): - resolved = rd - else: - # Allow explicit "output"/"projects" - if rd in ('output', 'output/'): - resolved = output_dir - elif rd in ('projects', 'projects/'): - resolved = projects_dir - else: - cand1 = os.path.join(output_dir, rd) - cand2 = os.path.join(projects_dir, rd) - # choose existing one if possible, otherwise default to cand1 - resolved = cand1 if os.path.exists(cand1) else ( - cand2 if os.path.exists(cand2) else cand1) - - resolved = os.path.normpath(os.path.abspath(resolved)) - - # Warning: Web UI is for local-only convenience (frontend/backend assumed localhost). - # For production, enforce strict backend file-access validation and authorization - # to prevent arbitrary path read/write (e.g., path traversal). - # TODO: Security check: ensure `resolved` is within configured allowed roots. - - return resolved - - -def resolve_file_path(root_dir_abs: str, file_path: str) -> str: - """ - Resolve file_path against root_dir_abs. - - if file_path starts with 'projects/', resolve from ms-agent base dir - - if file_path is absolute, use as-is - - if relative, join(root_dir_abs, file_path) - """ - root_dir_abs = os.path.normpath(os.path.abspath(root_dir_abs)) - - if os.path.isabs(file_path): - full_path = os.path.normpath(os.path.abspath(file_path)) - elif file_path.startswith('projects/'): - # Special case: if path starts with 'projects/', resolve from base_dir - # This handles: projects/code_genesis/output/config.js - base_dir = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - full_path = os.path.normpath( - os.path.abspath(os.path.join(base_dir, file_path))) - else: - # Try multiple locations - base_dir = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - - candidates = [ - # First try with root_dir_abs (for session-based access) - os.path.join(root_dir_abs, file_path), - # Then try in each project's output directory - ] - - # Search in project output directories - projects_dir = os.path.join(base_dir, 'projects') - if os.path.exists(projects_dir): - try: - for project_name in os.listdir(projects_dir): - project_path = os.path.join(projects_dir, project_name) - if os.path.isdir(project_path): - candidates.append( - os.path.join(project_path, 'output', file_path)) - except (OSError, PermissionError): - pass - - # Find first existing file - full_path = None - for candidate in candidates: - candidate = os.path.normpath(candidate) - if os.path.exists(candidate) and os.path.isfile(candidate): - full_path = candidate - break - - if not full_path: - # Default to first candidate if none found - full_path = os.path.normpath(candidates[0]) - - # Warning: Web UI is for local-only convenience (frontend/backend assumed localhost). - # For production, enforce strict backend file-access validation and authorization - # to prevent arbitrary path read/write (e.g., path traversal). - # TODO: Security check: ensure `full_path` is within configured allowed roots. - - return full_path - - -@router.post('/files/read') -async def read_file_content(request: FileReadRequest): - if request.session_id: - session_root = get_session_root(request.session_id) - root_abs = os.path.normpath(os.path.abspath(str(session_root))) - else: - root_abs = resolve_root_dir(request.root_dir) - full_path = resolve_file_path(root_abs, request.path) - - if not os.path.exists(full_path): - raise HTTPException( - status_code=404, detail=f'File not found: {full_path}') - - if not os.path.isfile(full_path): - raise HTTPException( - status_code=400, detail=f'Path {full_path} is not a file') - # limit 1MB - file_size = os.path.getsize(full_path) - if file_size > 1024 * 1024: - raise HTTPException(status_code=400, detail='File too large (max 1MB)') - - try: - with open(full_path, 'r', encoding='utf-8') as f: - content = f.read() - - ext = os.path.splitext(full_path)[1].lower() - lang_map = { - '.py': 'python', - '.js': 'javascript', - '.ts': 'typescript', - '.tsx': 'typescript', - '.jsx': 'javascript', - '.json': 'json', - '.yaml': 'yaml', - '.yml': 'yaml', - '.md': 'markdown', - '.html': 'html', - '.css': 'css', - '.txt': 'text', - '.sh': 'bash', - '.java': 'java', - '.go': 'go', - '.rs': 'rust', - } - language = lang_map.get(ext, 'text') - - # Return a relative path (relative to root_dir) for consistent handling on the frontend. - rel_path = os.path.relpath(full_path, root_abs) - - return { - 'content': content, - 'path': rel_path, - 'abs_path': full_path, - 'root_dir': root_abs, - 'filename': os.path.basename(full_path), - 'language': language, - 'size': file_size - } - except UnicodeDecodeError: - raise HTTPException(status_code=400, detail='File is not a text file') - except Exception as e: - raise HTTPException( - status_code=500, detail=f'Error reading file: {str(e)}') - - -def resolve_and_check_path(file_path: str) -> str: - """Resolve file path, trying multiple locations""" - base_dir = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - - if os.path.isabs(file_path): - full_path = file_path - else: - # Smart path resolution: - # If path already starts with 'projects/', use it directly under base_dir - # Otherwise try output_dir first, then search in project outputs - - candidates = [] - - # If path starts with 'projects/', join directly with base_dir - if file_path.startswith('projects/'): - candidates.append(os.path.join(base_dir, file_path)) - else: - # Try base_dir/output first - output_dir = os.path.join(base_dir, 'output') - candidates.append(os.path.join(output_dir, file_path)) - - # Try base_dir directly - candidates.append(os.path.join(base_dir, file_path)) - - # Search in each project's output directory - projects_dir = os.path.join(base_dir, 'projects') - if os.path.exists(projects_dir): - try: - for project_name in os.listdir(projects_dir): - project_path = os.path.join(projects_dir, project_name) - if os.path.isdir(project_path): - # Try project/output/filename - candidates.append( - os.path.join(project_path, 'output', - file_path)) - except (OSError, PermissionError): - pass - - # Find first existing file - full_path = None - for candidate in candidates: - candidate = os.path.normpath(candidate) - if os.path.exists(candidate) and os.path.isfile(candidate): - full_path = candidate - break - - if not full_path: - # If not found, use the first candidate for error message - full_path = os.path.normpath( - candidates[0] if candidates else file_path) - - full_path = os.path.normpath(full_path) - - # Warning: Web UI is for local-only convenience (frontend/backend assumed localhost). - # For production, enforce strict backend file-access validation and authorization - # to prevent arbitrary path read/write (e.g., path traversal). - # TODO: Security check: ensure `full_path` is within configured allowed roots. - - if not os.path.exists(full_path): - raise HTTPException( - status_code=404, detail=f'File not found: {full_path}') - if not os.path.isfile(full_path): - raise HTTPException( - status_code=400, detail=f'Path {full_path} is not a file') - - return full_path - - -@router.get('/files/stream') -async def stream_file(path: str, - session_id: Optional[str] = Query(default=None)): - if session_id: - session_root = get_session_root(session_id) - root_abs = str(session_root.resolve()) - full_path = resolve_file_path(root_abs, path) - else: - full_path = resolve_and_check_path(path) - - media_type, _ = mimetypes.guess_type(full_path) - media_type = media_type or 'application/octet-stream' - return FileResponse( - full_path, - media_type=media_type, - filename=os.path.basename(full_path), - headers={ - 'Content-Disposition': - f'inline; filename="{os.path.basename(full_path)}"' - }, - ) diff --git a/webui/backend/app/__init__.py b/webui/backend/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/webui/backend/app/api/__init__.py b/webui/backend/app/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/webui/backend/app/api/agent_settings.py b/webui/backend/app/api/agent_settings.py new file mode 100644 index 000000000..c7897d4eb --- /dev/null +++ b/webui/backend/app/api/agent_settings.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter + +from app.core import store +from app.core.envelope import EnvelopeRoute +from app.core.settings import settings +from app.schemas.agent_settings import AgentSettings + +router = APIRouter(prefix="/api/agent-settings", tags=["agent-settings"], + route_class=EnvelopeRoute) + + +def _ms() -> bool: + return settings.agent_backend == "ms_agent" + + +def _read() -> AgentSettings: + s = store.agent_settings + return AgentSettings( + default_provider_id=s.get("default_provider_id"), + default_model_id=s.get("default_model_id"), + default_memory_enabled=s.get("default_memory_enabled", True), + default_memory_backend=s.get("default_memory_backend", "file"), + global_mcp_auto_attach=s.get("global_mcp_auto_attach", True), + global_skill_auto_attach=s.get("global_skill_auto_attach", True), + ) + + +@router.get("") +def get_settings() -> AgentSettings: + if _ms(): + from app.backends.ms_agent import agent_settings + + return agent_settings.get_settings() + return _read() + + +@router.put("") +def update_settings(body: AgentSettings) -> AgentSettings: + if _ms(): + from app.backends.ms_agent import agent_settings + + return agent_settings.update_settings(body) + # Full replacement is fine — singleton row. + store.agent_settings.update(body.model_dump()) + return _read() diff --git a/webui/backend/app/api/chat.py b/webui/backend/app/api/chat.py new file mode 100644 index 000000000..f78ef6fb5 --- /dev/null +++ b/webui/backend/app/api/chat.py @@ -0,0 +1,89 @@ +from typing import Literal + +from fastapi import APIRouter +from pydantic import BaseModel +from sse_starlette.sse import EventSourceResponse + +from app.backends import get_backend +from app.core.envelope import EnvelopeRoute +from app.core.settings import settings +from app.schemas.chat import ChatRequest + +router = APIRouter(prefix="/api/chat", tags=["chat"], route_class=EnvelopeRoute) + + +@router.post("") +async def chat(req: ChatRequest): + # @ant-design/x-sdk's XStream splits SSE frames on "\n\n". sse-starlette + # defaults to CRLF, which causes the browser client to merge frames and drop + # intermediate deltas. Emit LF-separated events for this WebUI contract. + return EventSourceResponse(get_backend().chat_stream(req), sep="\n") + + +class ChatAttach(BaseModel): + """Re-attach a viewer to a session's in-flight turn: replays the turn's + events so far (catch-up) and follows the live tail — same ChatChunk SSE as + POST /api/chat. Used when the user navigates back to a session whose turn + kept running in the background. Emits just `done` when nothing is running.""" + + session_id: str + + +@router.post("/attach") +async def chat_attach(body: ChatAttach): + return EventSourceResponse( + get_backend().chat_attach(body.session_id), sep="\n" + ) + + +class PermissionResolve(BaseModel): + """Answer to a restricted-mode authorization card (step kind + "authorization"): the SSE turn is suspended on this request_id until it is + resolved here or the backend times out to deny.""" + + session_id: str + request_id: str + action: Literal["allow_once", "allow_always", "deny"] + + +@router.post("/permission") +async def resolve_permission(body: PermissionResolve) -> dict: + if settings.agent_backend == "ms_agent": + from app.backends.ms_agent.runtime import registry + + resolved = registry.resolve_permission( + body.session_id, body.request_id, body.action + ) + return {"resolved": resolved} + # mock backend never emits real asks; accept and ignore. + return {"resolved": True} + + +class ChatInterrupt(BaseModel): + """Explicit stop of a session's in-flight turn (the composer Stop button). + + This is distinct from merely closing the SSE (navigating away): a bare + disconnect keeps the turn running in the background, so leaving a + conversation does not stop it and other sessions run concurrently. Only this + call cancels the turn and seals it with an interrupted marker.""" + + session_id: str + + +@router.post("/interrupt") +async def interrupt_chat(body: ChatInterrupt) -> dict: + if settings.agent_backend == "ms_agent": + from app.backends.ms_agent.runtime import registry + + stopped = await registry.interrupt(body.session_id) + return {"stopped": stopped} + # mock backend has no live runtime to stop. + return {"stopped": True} + + +# NOTE: the former POST /api/chat/cancel (pagehide sendBeacon) was removed. +# pagehide also fires on a page REFRESH, so beacon-cancelling killed turns the +# user expected to survive. Product decision (aligned with the frontend team): +# a running turn is NEVER stopped by clients going away — navigation, refresh +# and a fully closed browser all leave it running to completion in the +# background. Only the explicit Stop (POST /api/chat/interrupt) cancels. diff --git a/webui/backend/app/api/instructions.py b/webui/backend/app/api/instructions.py new file mode 100644 index 000000000..c8cd1a442 --- /dev/null +++ b/webui/backend/app/api/instructions.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter, HTTPException + +from app.core import store +from app.core.envelope import EnvelopeRoute +from app.core.settings import settings +from app.schemas.instruction import Instruction, InstructionUpsert + +router = APIRouter(prefix="/api/instructions", tags=["instructions"], + route_class=EnvelopeRoute) + + +def _ms() -> bool: + return settings.agent_backend == "ms_agent" + + +def _validate_scope(scope: str) -> str: + try: + return store.scope_key(scope) + except ValueError as e: + raise HTTPException(400, str(e)) + + +@router.get("") +def get_instruction(scope: str) -> Instruction: + if _ms(): + from app.backends.ms_agent import instructions + + return instructions.get_instruction(scope) + key = _validate_scope(scope) + row = store.instructions.get(key) + if row is None: + row = {"scope": key, "content": "", "updated_at": store.now()} + return Instruction.model_validate(row) + + +@router.put("") +def upsert_instruction(scope: str, body: InstructionUpsert) -> Instruction: + if _ms(): + from app.backends.ms_agent import instructions + + return instructions.upsert_instruction(scope, body) + key = _validate_scope(scope) + row = {"scope": key, "content": body.content, "updated_at": store.now()} + store.instructions[key] = row + return Instruction.model_validate(row) diff --git a/webui/backend/app/api/mcps.py b/webui/backend/app/api/mcps.py new file mode 100644 index 000000000..53dbe8ebf --- /dev/null +++ b/webui/backend/app/api/mcps.py @@ -0,0 +1,125 @@ +from fastapi import APIRouter, HTTPException + +from app.core import store +from app.core.envelope import EnvelopeRoute +from app.core.settings import settings +from app.schemas.mcp import Mcp, McpCreate, McpHealth, McpUpdate + +router = APIRouter(prefix="/api/mcps", tags=["mcps"], route_class=EnvelopeRoute) + + +def _ms() -> bool: + return settings.agent_backend == "ms_agent" + + +def _validate_scope(scope: str) -> str: + try: + return store.scope_key(scope) + except ValueError as e: + raise HTTPException(400, str(e)) + + +@router.get("") +def list_mcps(scope: str | None = None) -> list[Mcp]: + if _ms(): + from app.backends.ms_agent import mcps + + return mcps.list_mcps(scope) + rows = list(store.mcps.values()) + if scope is not None: + normalized = _validate_scope(scope) + rows = [m for m in rows if m["scope"] == normalized] + rows.sort(key=lambda m: m["created_at"]) + return [Mcp.model_validate(m) for m in rows] + + +@router.get("/health") +def mcps_health() -> list[McpHealth]: + """Live reachability of enabled MCP servers (on-demand connect+initialize). + Defined before /{mcp_id} so the literal path wins over the id capture.""" + if _ms(): + from app.backends.ms_agent import mcps + + return mcps.health() + # Mock backend has no real servers; report enabled ones as healthy. + return [ + McpHealth(id=m["id"], name=m["name"], scope=m["scope"], healthy=True) + for m in store.mcps.values() + if m.get("enabled", True) + ] + + +@router.get("/{mcp_id}/health") +def mcp_health_check(mcp_id: str) -> McpHealth: + """Probe a single MCP server's connectivity. Returns healthy + error reason.""" + if _ms(): + from app.backends.ms_agent import mcps + + return mcps.health_one(mcp_id) + row = store.mcps.get(mcp_id) + if not row: + raise HTTPException(404, "mcp not found") + return McpHealth( + id=mcp_id, name=row["name"], scope=row["scope"], healthy=True + ) + + +@router.post("", status_code=201) +def create_mcp(body: McpCreate) -> Mcp: + if _ms(): + from app.backends.ms_agent import mcps + + return mcps.create_mcp(body) + scope = _validate_scope(body.scope) + mid = store.new_id("m-") + row = { + "id": mid, + "name": body.name, + "description": body.description, + "transport": body.transport, + "endpoint": body.endpoint, + "enabled": body.enabled, + "scope": scope, + "created_at": store.now(), + } + store.mcps[mid] = row + return Mcp.model_validate(row) + + +@router.get("/{mcp_id}") +def get_mcp(mcp_id: str) -> Mcp: + if _ms(): + from app.backends.ms_agent import mcps + + return mcps.get_mcp(mcp_id) + row = store.mcps.get(mcp_id) + if not row: + raise HTTPException(404, "mcp not found") + return Mcp.model_validate(row) + + +@router.patch("/{mcp_id}") +def update_mcp(mcp_id: str, body: McpUpdate) -> Mcp: + if _ms(): + from app.backends.ms_agent import mcps + + return mcps.update_mcp(mcp_id, body) + row = store.mcps.get(mcp_id) + if not row: + raise HTTPException(404, "mcp not found") + for field in ("name", "description", "transport", "endpoint", "enabled"): + value = getattr(body, field) + if value is not None: + row[field] = value + return Mcp.model_validate(row) + + +@router.delete("/{mcp_id}", status_code=204) +def delete_mcp(mcp_id: str) -> None: + if _ms(): + from app.backends.ms_agent import mcps + + return mcps.delete_mcp(mcp_id) + if mcp_id not in store.mcps: + raise HTTPException(404, "mcp not found") + del store.mcps[mcp_id] diff --git a/webui/backend/app/api/memory.py b/webui/backend/app/api/memory.py new file mode 100644 index 000000000..9601ab653 --- /dev/null +++ b/webui/backend/app/api/memory.py @@ -0,0 +1,100 @@ +"""Project-scoped memory items. + +Memory is gated per-project. Default project never has memory. Each project +that has `memory_enabled=True` holds a list of content items (no path/filename). +""" + +from fastapi import APIRouter, HTTPException + +from app.core import store +from app.core.envelope import EnvelopeRoute +from app.core.settings import settings +from app.schemas.memory import MemoryItem, MemoryItemCreate, MemoryItemUpdate + +router = APIRouter(prefix="/api/projects/{project_id}/memory", tags=["memory"], + route_class=EnvelopeRoute) + + +def _ms() -> bool: + return settings.agent_backend == "ms_agent" + + +def _project(project_id: str) -> dict: + row = store.projects.get(project_id) + if not row: + raise HTTPException(404, "project not found") + if row["is_default"]: + raise HTTPException(400, "default project does not support memory") + if not row.get("memory_enabled"): + raise HTTPException(400, "memory is disabled for this project") + return row + + +def _items(project_id: str) -> list[dict]: + return store.memory_files.setdefault(project_id, []) + + +def _find(project_id: str, item_id: str) -> dict | None: + for item in _items(project_id): + if item["id"] == item_id: + return item + return None + + +@router.get("/items") +def list_items(project_id: str) -> list[MemoryItem]: + if _ms(): + from app.backends.ms_agent import memory + + return memory.list_items(project_id) + _project(project_id) + rows = sorted(_items(project_id), key=lambda x: x.get("updated_at", "")) + return [MemoryItem.model_validate(r) for r in rows] + + +@router.post("/items", status_code=201) +def create_item(project_id: str, body: MemoryItemCreate) -> MemoryItem: + if _ms(): + from app.backends.ms_agent import memory + + return memory.create_item(project_id, body) + _project(project_id) + row = { + "id": store.new_id("mem_"), + "project_id": project_id, + "content": body.content, + "updated_at": store.now(), + } + _items(project_id).append(row) + return MemoryItem.model_validate(row) + + +@router.put("/items/{item_id}") +def update_item(project_id: str, item_id: str, + body: MemoryItemUpdate) -> MemoryItem: + if _ms(): + from app.backends.ms_agent import memory + + return memory.update_item(project_id, item_id, body) + _project(project_id) + row = _find(project_id, item_id) + if not row: + raise HTTPException(404, "memory item not found") + row["content"] = body.content + row["updated_at"] = store.now() + return MemoryItem.model_validate(row) + + +@router.delete("/items/{item_id}", status_code=204) +def delete_item(project_id: str, item_id: str) -> None: + if _ms(): + from app.backends.ms_agent import memory + + return memory.delete_item(project_id, item_id) + _project(project_id) + items = _items(project_id) + for i, item in enumerate(items): + if item["id"] == item_id: + items.pop(i) + return + raise HTTPException(404, "memory item not found") diff --git a/webui/backend/app/api/models.py b/webui/backend/app/api/models.py new file mode 100644 index 000000000..45a02f7b5 --- /dev/null +++ b/webui/backend/app/api/models.py @@ -0,0 +1,78 @@ +from fastapi import APIRouter, HTTPException + +from app.core import store +from app.core.envelope import EnvelopeRoute +from app.core.settings import settings +from app.schemas.model import Model, ModelCreate, ModelUpdate + +router = APIRouter(prefix="/api/models", tags=["models"], + route_class=EnvelopeRoute) + + +def _ms() -> bool: + return settings.agent_backend == "ms_agent" + + +@router.get("") +def list_models(provider_id: str | None = None) -> list[Model]: + if _ms(): + from app.backends.ms_agent import models + + return models.list_models(provider_id) + rows = list(store.models.values()) + if provider_id is not None: + rows = [m for m in rows if m["provider_id"] == provider_id] + rows.sort(key=lambda m: m["created_at"]) + return [Model.model_validate(m) for m in rows] + + +@router.post("", status_code=201) +def create_model(body: ModelCreate) -> Model: + if _ms(): + from app.backends.ms_agent import models + + return models.create_model(body) + if body.provider_id not in store.providers: + raise HTTPException(400, "unknown provider") + mid = store.new_id("md-") + row = { + "id": mid, + "provider_id": body.provider_id, + "name": body.name, + "display_name": body.display_name or body.name, + "is_builtin": False, + "advanced_params": body.advanced_params, + "created_at": store.now(), + } + store.models[mid] = row + return Model.model_validate(row) + + +@router.patch("/{model_id}") +def update_model(model_id: str, body: ModelUpdate) -> Model: + if _ms(): + from app.backends.ms_agent import models + + return models.update_model(model_id, body) + row = store.models.get(model_id) + if not row: + raise HTTPException(404, "model not found") + if body.display_name is not None: + row["display_name"] = body.display_name + if body.advanced_params is not None: + row["advanced_params"] = body.advanced_params + return Model.model_validate(row) + + +@router.delete("/{model_id}", status_code=204) +def delete_model(model_id: str) -> None: + if _ms(): + from app.backends.ms_agent import models + + return models.delete_model(model_id) + row = store.models.get(model_id) + if not row: + raise HTTPException(404, "model not found") + if row["is_builtin"]: + raise HTTPException(400, "cannot delete builtin model") + del store.models[model_id] diff --git a/webui/backend/app/api/presence.py b/webui/backend/app/api/presence.py new file mode 100644 index 000000000..c8ae88ee1 --- /dev/null +++ b/webui/backend/app/api/presence.py @@ -0,0 +1,29 @@ +"""Running-state poll for the app shell. + +The frontend polls here every ~10s. The response carries the ids of sessions +with a turn in flight, which drives the sidebar "running" spinners, triggers +the live re-attach when the user opens a running session, and lets the client +reload lists/history the moment a background turn finishes. + +This is NOT a liveness contract: by product decision (aligned with the +frontend team), a running turn is never stopped because clients went away — +navigation, refresh and even a fully closed browser all leave it running to +completion in the background. Only the explicit Stop button +(POST /api/chat/interrupt) cancels a turn. +""" +from fastapi import APIRouter + +from app.core.envelope import EnvelopeRoute +from app.core.settings import settings + +router = APIRouter(prefix="/api", tags=["presence"], route_class=EnvelopeRoute) + + +@router.post("/presence") +async def presence() -> dict: + if settings.agent_backend == "ms_agent": + from app.backends.ms_agent.runtime import registry + + return {"running": registry.running_sessions()} + # mock backend has no live runtimes. + return {"running": []} diff --git a/webui/backend/app/api/profile.py b/webui/backend/app/api/profile.py new file mode 100644 index 000000000..ca83a98ee --- /dev/null +++ b/webui/backend/app/api/profile.py @@ -0,0 +1,40 @@ +from fastapi import APIRouter + +from app.core import store +from app.core.envelope import EnvelopeRoute +from app.core.settings import settings +from app.schemas.profile import Profile, ProfileUpsert + +router = APIRouter(prefix="/api/profile", tags=["profile"], + route_class=EnvelopeRoute) + + +def _ms() -> bool: + return settings.agent_backend == "ms_agent" + + +def _read() -> Profile: + return Profile.model_validate(store.profile) + + +@router.get("") +def get_profile() -> Profile: + if _ms(): + from app.backends.ms_agent import profile + + return profile.get_profile() + return _read() + + +@router.put("") +def update_profile(body: ProfileUpsert) -> Profile: + if _ms(): + from app.backends.ms_agent import profile + + return profile.update_profile(body) + if body.agent_calls_user is not None: + store.profile["agent_calls_user"] = body.agent_calls_user + if body.description is not None: + store.profile["description"] = body.description + store.profile["updated_at"] = store.now() + return _read() diff --git a/webui/backend/app/api/projects.py b/webui/backend/app/api/projects.py new file mode 100644 index 000000000..d30e65461 --- /dev/null +++ b/webui/backend/app/api/projects.py @@ -0,0 +1,138 @@ +from fastapi import APIRouter, HTTPException + +from app.core import store +from app.core.envelope import EnvelopeRoute +from app.core.settings import settings +from app.schemas.project import Project, ProjectCreate, ProjectUpdate + +router = APIRouter(prefix="/api/projects", tags=["projects"], + route_class=EnvelopeRoute) + + +def _ms() -> bool: + return settings.agent_backend == "ms_agent" + + +@router.get("") +def list_projects() -> list[Project]: + if _ms(): + from app.backends.ms_agent import projects + + return projects.list_projects() + # Default project first, then named ones by creation order. + rows = sorted( + store.projects.values(), + key=lambda p: (not p["is_default"], p["created_at"]), + ) + return [Project.model_validate(p) for p in rows] + + +@router.post("", status_code=201) +def create_project(body: ProjectCreate) -> Project: + if _ms(): + from app.backends.ms_agent import projects + + return projects.create_project(body) + pid = store.new_id("p-") + s = store.agent_settings + row = { + "id": + pid, + "name": + body.name, + "description": + body.description, + "local_path": + body.local_path, + "is_default": + False, + "memory_enabled": + (body.memory_enabled if body.memory_enabled is not None else s.get( + "default_memory_enabled", True)), + "memory_backend": + (body.memory_backend if body.memory_backend is not None else s.get( + "default_memory_backend", "file")), + "mcp_auto_attach": + True, + "skill_auto_attach": + True, + "created_at": + store.now(), + } + store.projects[pid] = row + return Project.model_validate(row) + + +@router.get("/{project_id}") +def get_project(project_id: str) -> Project: + if _ms(): + from app.backends.ms_agent import projects + + return projects.get_project(project_id) + row = store.projects.get(project_id) + if not row: + raise HTTPException(404, "project not found") + return Project.model_validate(row) + + +@router.patch("/{project_id}") +def update_project(project_id: str, body: ProjectUpdate) -> Project: + if _ms(): + from app.backends.ms_agent import projects + + return projects.update_project(project_id, body) + row = store.projects.get(project_id) + if not row: + raise HTTPException(404, "project not found") + if body.name is not None: + row["name"] = body.name + if body.description is not None: + row["description"] = body.description + if body.local_path is not None: + row["local_path"] = body.local_path + if body.memory_enabled is not None: + if row["is_default"] and body.memory_enabled: + raise HTTPException(400, "default project cannot enable memory") + row["memory_enabled"] = body.memory_enabled + if body.mcp_auto_attach is not None: + row["mcp_auto_attach"] = body.mcp_auto_attach + if body.skill_auto_attach is not None: + row["skill_auto_attach"] = body.skill_auto_attach + if body.permission_mode is not None: + row["permission_mode"] = body.permission_mode + return Project.model_validate(row) + + +@router.delete("/{project_id}", status_code=204) +def delete_project(project_id: str) -> None: + if _ms(): + from app.backends.ms_agent import projects + + return projects.delete_project(project_id) + row = store.projects.get(project_id) + if not row: + raise HTTPException(404, "project not found") + if row["is_default"]: + raise HTTPException(400, "cannot delete default project") + del store.projects[project_id] + # Cascade: orphan sessions move to default; drop project-owned resources. + for s in store.sessions.values(): + if s.get("project_id") == project_id: + s["project_id"] = store.DEFAULT_PROJECT_ID + store.memory_files.pop(project_id, None) + store.workspace_files.pop(project_id, None) + # Remove project-scoped instructions + store.instructions.pop(f"project:{project_id}", None) + # Remove project-scoped MCPs + scope_key = f"project:{project_id}" + mcp_ids_to_remove = [ + k for k, v in store.mcps.items() if v.get("scope") == scope_key + ] + for mid in mcp_ids_to_remove: + del store.mcps[mid] + # Remove project-scoped skills + skill_ids_to_remove = [ + k for k, v in store.skills.items() if v.get("scope") == scope_key + ] + for sid in skill_ids_to_remove: + del store.skills[sid] diff --git a/webui/backend/app/api/providers.py b/webui/backend/app/api/providers.py new file mode 100644 index 000000000..94343018e --- /dev/null +++ b/webui/backend/app/api/providers.py @@ -0,0 +1,136 @@ +from fastapi import APIRouter, HTTPException + +from app.core import store +from app.core.envelope import EnvelopeRoute +from app.core.settings import settings +from app.schemas.provider import Provider, ProviderCreate, ProviderUpdate + +router = APIRouter(prefix="/api/providers", tags=["providers"], + route_class=EnvelopeRoute) + + +def _ms() -> bool: + return settings.agent_backend == "ms_agent" + + +def _mask(api_key: str) -> str: + if not api_key: + return "" + if len(api_key) <= 8: + return "****" + return f"{api_key[:4]}****{api_key[-4:]}" + + +@router.get("") +def list_providers() -> list[Provider]: + if _ms(): + from app.backends.ms_agent import providers + + return providers.list_providers() + rows = sorted( + store.providers.values(), + key=lambda p: (p["kind"] != "builtin", p["created_at"]), + ) + return [Provider.model_validate(p) for p in rows] + + +@router.post("", status_code=201) +def create_provider(body: ProviderCreate) -> Provider: + if _ms(): + from app.backends.ms_agent import providers + + return providers.create_provider(body) + if body.id in store.providers: + raise HTTPException(409, "provider id already exists") + row = { + "id": body.id, + "kind": "custom", + "name": body.name, + "base_url": body.base_url, + "api_key_masked": "", + "api_key": "", + "protocol": body.protocol, + "enabled": True, + "default_generation_params": body.default_generation_params, + "created_at": store.now(), + } + store.providers[body.id] = row + return Provider.model_validate(row) + + +@router.get("/{provider_id}") +def get_provider(provider_id: str) -> Provider: + if _ms(): + from app.backends.ms_agent import providers + + return providers.get_provider(provider_id) + row = store.providers.get(provider_id) + if not row: + raise HTTPException(404, "provider not found") + return Provider.model_validate(row) + + +@router.patch("/{provider_id}") +def update_provider(provider_id: str, body: ProviderUpdate) -> Provider: + if _ms(): + from app.backends.ms_agent import providers + + return providers.update_provider(provider_id, body) + row = store.providers.get(provider_id) + if not row: + raise HTTPException(404, "provider not found") + if body.name is not None: + row["name"] = body.name + if body.base_url is not None: + row["base_url"] = body.base_url + if body.protocol is not None: + row["protocol"] = body.protocol + if body.enabled is not None: + row["enabled"] = body.enabled + if body.api_key is not None: + row["api_key_masked"] = _mask(body.api_key) + row["api_key"] = body.api_key + if body.default_generation_params is not None: + row["default_generation_params"] = body.default_generation_params + return Provider.model_validate(row) + + +@router.delete("/{provider_id}", status_code=204) +def delete_provider(provider_id: str) -> None: + if _ms(): + from app.backends.ms_agent import providers + + return providers.delete_provider(provider_id) + row = store.providers.get(provider_id) + if not row: + raise HTTPException(404, "provider not found") + if row["kind"] == "builtin": + raise HTTPException(400, "cannot delete builtin provider") + del store.providers[provider_id] + # Cascade: drop models bound to this provider. + for mid in [ + m["id"] for m in store.models.values() + if m["provider_id"] == provider_id + ]: + del store.models[mid] + + +@router.get("/{provider_id}/available-models") +def available_models(provider_id: str) -> list[str]: + """Best-effort model-id discovery via the provider's standard /models + endpoint. Returns [] on any failure (missing key, network error, etc.).""" + from app.core.model_discovery import fetch_model_ids + + if _ms(): + from app.backends.ms_agent import providers + + base_url, protocol, api_key = providers.get_provider_secret( + provider_id) + else: + row = store.providers.get(provider_id) + if not row: + raise HTTPException(404, "provider not found") + base_url = row.get("base_url", "") + protocol = row.get("protocol", "openai") + api_key = row.get("api_key", "") + return fetch_model_ids(base_url, protocol, api_key) diff --git a/webui/backend/app/api/sessions.py b/webui/backend/app/api/sessions.py new file mode 100644 index 000000000..032cf4abe --- /dev/null +++ b/webui/backend/app/api/sessions.py @@ -0,0 +1,118 @@ +from fastapi import APIRouter, HTTPException + +from app.core import store +from app.core.envelope import EnvelopeRoute +from app.core.settings import settings +from app.schemas.session import ( + Artifact, Session, SessionCreate, SessionMessage, SessionPlan, SessionUpdate +) + +router = APIRouter(prefix="/api", tags=["sessions"], route_class=EnvelopeRoute) + + +def _ms() -> bool: + return settings.agent_backend == "ms_agent" + + +@router.get("/sessions") +def list_sessions(project_id: str | None = None) -> list[Session]: + if _ms(): + from app.backends.ms_agent import sessions + + return sessions.list_sessions(project_id) + rows = list(store.sessions.values()) + if project_id is not None: + rows = [s for s in rows if s.get("project_id") == project_id] + rows.sort(key=lambda s: s["updated_at"], reverse=True) + return [Session.model_validate(s) for s in rows] + + +@router.post("/sessions", status_code=201) +def create_session(body: SessionCreate) -> Session: + if _ms(): + from app.backends.ms_agent import sessions + + return sessions.create_session(body) + sid = store.new_id("s-") + row = { + "id": sid, + "title": body.title, + "project_id": body.project_id or store.DEFAULT_PROJECT_ID, + "updated_at": store.now(), + "preview": body.preview, + } + store.sessions[sid] = row + return Session.model_validate(row) + + +@router.get("/sessions/{session_id}") +def get_session(session_id: str) -> Session: + if _ms(): + from app.backends.ms_agent import sessions + + return sessions.get_session(session_id) + row = store.sessions.get(session_id) + if not row: + raise HTTPException(404, "session not found") + return Session.model_validate(row) + + +@router.get("/sessions/{session_id}/messages") +def list_session_messages(session_id: str) -> list[SessionMessage]: + if _ms(): + from app.backends.ms_agent import sessions + + return sessions.list_messages(session_id) + if session_id not in store.sessions: + raise HTTPException(404, "session not found") + return [] + + +@router.get("/sessions/{session_id}/plan") +def get_session_plan(session_id: str) -> SessionPlan: + """The latest plan.json for the session, plus whether it belongs to the + CURRENT running turn (``active`` — the server-side truth the composer uses + to animate running rows). Always reflects the live plan file (tool writes + + manual edits) and ignores the chat/session log.""" + if _ms(): + from app.backends.ms_agent import sessions + + return sessions.read_plan(session_id) + return SessionPlan() + + +@router.delete("/sessions/{session_id}", status_code=204) +def delete_session(session_id: str) -> None: + if _ms(): + from app.backends.ms_agent import sessions + + return sessions.delete_session(session_id) + if session_id not in store.sessions: + raise HTTPException(404, "session not found") + del store.sessions[session_id] + store.artifacts.pop(session_id, None) + + +@router.patch("/sessions/{session_id}") +def update_session(session_id: str, body: SessionUpdate) -> Session: + """Rename a session (update its title).""" + if _ms(): + from app.backends.ms_agent import sessions + + return sessions.update_session(session_id, body) + row = store.sessions.get(session_id) + if not row: + raise HTTPException(404, "session not found") + if body.title is not None: + row["title"] = body.title + return Session.model_validate(row) + + +@router.get("/sessions/{session_id}/artifacts") +def list_artifacts(session_id: str) -> list[Artifact]: + if _ms(): + from app.backends.ms_agent import sessions + + return sessions.list_artifacts(session_id) + rows = store.artifacts.get(session_id, []) + return [Artifact.model_validate(a) for a in rows] diff --git a/webui/backend/app/api/skills.py b/webui/backend/app/api/skills.py new file mode 100644 index 000000000..6ea0263c9 --- /dev/null +++ b/webui/backend/app/api/skills.py @@ -0,0 +1,128 @@ +from fastapi import APIRouter, HTTPException + +from app.core import store +from app.core.envelope import EnvelopeRoute +from app.core.settings import settings +from app.schemas.skill import ( + Skill, + SkillCreate, + SkillFile, + SkillFileContent, + SkillUpdate, +) + +router = APIRouter(prefix="/api/skills", tags=["skills"], + route_class=EnvelopeRoute) + + +def _ms() -> bool: + return settings.agent_backend == "ms_agent" + + +def _validate_scope(scope: str) -> str: + try: + return store.scope_key(scope) + except ValueError as e: + raise HTTPException(400, str(e)) + + +@router.get("") +def list_skills(scope: str | None = None) -> list[Skill]: + if _ms(): + from app.backends.ms_agent import skills + + return skills.list_skills(scope) + rows = list(store.skills.values()) + if scope is not None: + normalized = _validate_scope(scope) + rows = [s for s in rows if s["scope"] == normalized] + rows.sort(key=lambda s: s["created_at"]) + return [Skill.model_validate(s) for s in rows] + + +@router.post("", status_code=201) +def create_skill(body: SkillCreate) -> Skill: + if _ms(): + from app.backends.ms_agent import skills + + return skills.create_skill(body) + scope = _validate_scope(body.scope) + sk_id = store.new_id("sk-") + row = { + "id": sk_id, + "name": body.name, + "kind": body.kind, + "content": body.content, + "enabled": body.enabled, + "scope": scope, + "created_at": store.now(), + } + store.skills[sk_id] = row + return Skill.model_validate(row) + + +@router.get("/{skill_id}") +def get_skill(skill_id: str) -> Skill: + if _ms(): + from app.backends.ms_agent import skills + + return skills.get_skill(skill_id) + row = store.skills.get(skill_id) + if not row: + raise HTTPException(404, "skill not found") + return Skill.model_validate(row) + + +@router.get("/{skill_id}/files") +def list_skill_files(skill_id: str) -> list[SkillFile]: + """Real file listing of the skill's on-disk directory (viewer tree).""" + if _ms(): + from app.backends.ms_agent import skills + + return skills.list_skill_files(skill_id) + row = store.skills.get(skill_id) + if not row: + raise HTTPException(404, "skill not found") + return [SkillFile(path="SKILL.md")] + + +@router.get("/{skill_id}/file") +def read_skill_file(skill_id: str, path: str) -> SkillFileContent: + """UTF-8 content of one skill file; ``content=null`` marks binary.""" + if _ms(): + from app.backends.ms_agent import skills + + return skills.read_skill_file(skill_id, path) + row = store.skills.get(skill_id) + if not row: + raise HTTPException(404, "skill not found") + if path != "SKILL.md": + raise HTTPException(404, "file not found") + return SkillFileContent(path="SKILL.md", content=row["content"]) + + +@router.patch("/{skill_id}") +def update_skill(skill_id: str, body: SkillUpdate) -> Skill: + if _ms(): + from app.backends.ms_agent import skills + + return skills.update_skill(skill_id, body) + row = store.skills.get(skill_id) + if not row: + raise HTTPException(404, "skill not found") + for field in ("name", "kind", "content", "enabled"): + value = getattr(body, field) + if value is not None: + row[field] = value + return Skill.model_validate(row) + + +@router.delete("/{skill_id}", status_code=204) +def delete_skill(skill_id: str) -> None: + if _ms(): + from app.backends.ms_agent import skills + + return skills.delete_skill(skill_id) + if skill_id not in store.skills: + raise HTTPException(404, "skill not found") + del store.skills[skill_id] diff --git a/webui/backend/app/api/workspace.py b/webui/backend/app/api/workspace.py new file mode 100644 index 000000000..913dff731 --- /dev/null +++ b/webui/backend/app/api/workspace.py @@ -0,0 +1,274 @@ +"""Project-scoped workspace files. + +Iter-3 scope: real CRUD against an in-memory mock so the UI can stop using the +hard-coded tree in SessionRightRail. Upload / download / Import are stubbed — +the frontend exposes the affordances but they POST/PUT plain JSON through +this same surface. +""" + +import time + +from fastapi import APIRouter, File, Form, HTTPException, UploadFile +from fastapi.responses import Response + +from app.core import store +from app.core.envelope import EnvelopeRoute +from app.core.filetypes import guess_type, is_binary_ext +from app.core.settings import settings +from app.schemas.workspace import ( + WorkspaceFile, + WorkspaceFileCreate, + WorkspaceFileMove, + WorkspaceFileUpdate, +) + +router = APIRouter( + prefix="/api/projects/{project_id}/workspace", + tags=["workspace"], + route_class=EnvelopeRoute, +) + + +def _ms() -> bool: + return settings.agent_backend == "ms_agent" + + +def _project(project_id: str) -> dict: + row = store.projects.get(project_id) + if not row: + raise HTTPException(404, "project not found") + return row + + +def _files(project_id: str) -> list[dict]: + return store.workspace_files.setdefault(project_id, []) + + +def _find(project_id: str, path: str) -> dict | None: + for f in _files(project_id): + if f["path"] == path: + return f + return None + + +def _dedup_rel(project_id: str, rel: str, data: bytes) -> str: + """Non-clobbering relative path for a mock-backend dedup upload: the first + upload of a name keeps it; a later same-named upload reuses that first row + when the bytes are identical, else is timestamped + (``-``) — mirrors ms_agent workspace._dedup_target; a + same-ms collision falls back to a counter.""" + row = _find(project_id, rel) + if row is None or row.get("_data") == data: + return rel # free name, or identical re-upload of the first → reuse + stem, dot, ext = rel.rpartition(".") + base, suffix = (stem, f".{ext}") if dot else (rel, "") + ts = int(time.time() * 1000) + cand = f"{base}-{ts}{suffix}" + i = 1 + while _find(project_id, cand) is not None: + cand = f"{base}-{ts}-{i}{suffix}" + i += 1 + return cand + + +@router.get("/files") +def list_files(project_id: str) -> list[WorkspaceFile]: + if _ms(): + from app.backends.ms_agent import workspace + + return workspace.list_files(project_id) + _project(project_id) + rows = sorted(_files(project_id), key=lambda f: f["path"]) + return [WorkspaceFile.model_validate(f) for f in rows] + + +@router.post("/files", status_code=201) +def create_file(project_id: str, body: WorkspaceFileCreate) -> WorkspaceFile: + if _ms(): + from app.backends.ms_agent import workspace + + return workspace.create_file(project_id, body) + _project(project_id) + if _find(project_id, body.path) is not None: + raise HTTPException(409, "file already exists") + row = { + "project_id": + project_id, + "path": + body.path, + "kind": + body.kind, + "size": + body.size if body.size is not None else len( + body.content.encode("utf-8")), + "updated_at": + store.now(), + "preview": + body.content[:200] if body.content else None, + "content": + None if body.kind == "folder" or is_binary_ext(body.path) else + body.content, + "content_type": + None if body.kind == "folder" else guess_type(body.path), + } + _files(project_id).append(row) + return WorkspaceFile.model_validate(row) + + +@router.post("/files/move") +def move_file(project_id: str, body: WorkspaceFileMove) -> WorkspaceFile: + """Rename/move a file or folder. Folder moves rewrite every child path.""" + if _ms(): + from app.backends.ms_agent import workspace + + return workspace.move_file(project_id, body.src, body.dst) + _project(project_id) + src, dst = body.src, body.dst + if src == dst: + row = _find(project_id, src) + if row is None: + raise HTTPException(404, "file not found") + return WorkspaceFile.model_validate(row) + if _find(project_id, dst) is not None: + raise HTTPException(409, "target already exists") + files = _files(project_id) + moved: dict | None = None + touched = False + for f in files: + p = f["path"] + if p == src: + f["path"] = dst + f["updated_at"] = store.now() + moved = f + touched = True + elif p.startswith(src + "/"): + f["path"] = dst + p[len(src):] + f["updated_at"] = store.now() + touched = True + if not touched: + raise HTTPException(404, "file not found") + if moved is None: + # Folder with no own row (only children moved): synthesize a folder row. + moved = { + "project_id": project_id, + "path": dst, + "kind": "folder", + "size": 0, + "updated_at": store.now(), + } + return WorkspaceFile.model_validate(moved) + + +@router.get("/files/{file_path:path}/raw") +def raw_file(project_id: str, file_path: str) -> Response: + """Serve raw file bytes (for media preview / download). Not enveloped: the + EnvelopeRoute only wraps JSON responses, so binary passes through as-is.""" + if _ms(): + from app.backends.ms_agent import workspace + + target, ctype = workspace.raw_file(project_id, file_path) + return Response(content=target.read_bytes(), media_type=ctype) + _project(project_id) + row = _find(project_id, file_path) + if not row or row.get("kind") == "folder": + raise HTTPException(404, "file not found") + data = row.get("_data") + if data is None: + data = (row.get("content") or "").encode("utf-8") + ctype = row.get("content_type") or guess_type(file_path) \ + or "application/octet-stream" + return Response(content=data, media_type=ctype) + + +@router.post("/files/upload", status_code=201) +async def upload_file( + project_id: str, + file: UploadFile = File(...), + path: str | None = Form(None), + dedup: bool = Form(False), +) -> WorkspaceFile: + """Binary-safe upload via multipart/form-data. Raw bytes are written to disk + unchanged. A same-path file is overwritten by default; with ``dedup`` (chat + attachments into ``user_files/``) a same-named-but-different file is + auto-suffixed and the returned ``path`` is the real, deduped location.""" + rel = (path or file.filename or "").strip() + if not rel: + raise HTTPException(422, "missing file path") + data = await file.read() + if _ms(): + from app.backends.ms_agent import workspace + + return workspace.save_upload(project_id, rel, data, dedup=dedup) + _project(project_id) + try: + text: str | None = data.decode("utf-8") + except UnicodeDecodeError: + text = None + if is_binary_ext(rel): + text = None # never inline archives/media/etc. as text + ctype = file.content_type or guess_type(rel) or "application/octet-stream" + # dedup (chat attachments): the first upload of a name keeps it; a later + # same-named upload reuses it when the bytes match, else is timestamped. + # Parity with the ms_agent backend's _dedup_target. + if dedup: + rel = _dedup_rel(project_id, rel, data) + existing = _find(project_id, rel) + row = existing + if row is None: + row = {"project_id": project_id, "path": rel, "kind": "file"} + _files(project_id).append(row) + row.update({ + "kind": "file", + "size": len(data), + "updated_at": store.now(), + "preview": text[:200] if text else None, + "content": text, + "content_type": ctype, + "_data": data, + }) + return WorkspaceFile.model_validate(row) + + +@router.get("/files/{file_path:path}") +def get_file(project_id: str, file_path: str) -> WorkspaceFile: + if _ms(): + from app.backends.ms_agent import workspace + + return workspace.get_file(project_id, file_path) + _project(project_id) + row = _find(project_id, file_path) + if not row: + raise HTTPException(404, "file not found") + return WorkspaceFile.model_validate(row) + + +@router.put("/files/{file_path:path}") +def update_file(project_id: str, file_path: str, + body: WorkspaceFileUpdate) -> WorkspaceFile: + if _ms(): + from app.backends.ms_agent import workspace + + return workspace.update_file(project_id, file_path, body) + _project(project_id) + row = _find(project_id, file_path) + if not row: + raise HTTPException(404, "file not found") + row["size"] = len(body.content.encode("utf-8")) + row["preview"] = body.content[:200] if body.content else None + row["updated_at"] = store.now() + return WorkspaceFile.model_validate(row) + + +@router.delete("/files/{file_path:path}", status_code=204) +def delete_file(project_id: str, file_path: str) -> None: + if _ms(): + from app.backends.ms_agent import workspace + + return workspace.delete_file(project_id, file_path) + _project(project_id) + files = _files(project_id) + for i, f in enumerate(files): + if f["path"] == file_path: + files.pop(i) + return + raise HTTPException(404, "file not found") diff --git a/webui/backend/app/backends/__init__.py b/webui/backend/app/backends/__init__.py new file mode 100644 index 000000000..d7ca4c3be --- /dev/null +++ b/webui/backend/app/backends/__init__.py @@ -0,0 +1,22 @@ +"""Backend selection. + +``get_backend()`` returns a process-wide singleton chosen by +``settings.agent_backend``. The ms_agent backend is imported lazily so that +running in ``mock`` mode never requires the ms-agent SDK to be installed. +""" +from __future__ import annotations + +from functools import lru_cache + +from app.core.settings import settings + + +@lru_cache(maxsize=1) +def get_backend(): + if settings.agent_backend == "ms_agent": + from app.backends.ms_agent.backend import MsAgentBackend + + return MsAgentBackend() + from app.backends.mock import MockBackend + + return MockBackend() diff --git a/webui/backend/app/backends/errors.py b/webui/backend/app/backends/errors.py new file mode 100644 index 000000000..4196ec977 --- /dev/null +++ b/webui/backend/app/backends/errors.py @@ -0,0 +1,23 @@ +"""Backend-domain errors that map straight to HTTP status codes. + +Subclassing HTTPException lets adapters raise semantic errors while FastAPI +renders them natively — routes stay thin and don't repeat status mapping. +""" +from __future__ import annotations + +from fastapi import HTTPException + + +class NotFound(HTTPException): + def __init__(self, detail: str = "not found") -> None: + super().__init__(404, detail) + + +class BadRequest(HTTPException): + def __init__(self, detail: str = "bad request") -> None: + super().__init__(400, detail) + + +class Conflict(HTTPException): + def __init__(self, detail: str = "conflict") -> None: + super().__init__(409, detail) diff --git a/webui/backend/app/backends/mock.py b/webui/backend/app/backends/mock.py new file mode 100644 index 000000000..24bf148ea --- /dev/null +++ b/webui/backend/app/backends/mock.py @@ -0,0 +1,113 @@ +"""Mock backend — in-memory seed data, no SDK required. + +Chat streaming lives here (moved out of api/chat.py). Management endpoints still +read/write app.core.store directly in their routes when running on this backend; +methods are added here only as domains are ported to the facade. + +The stream emits the same task/step view-model the real backend produces +(frontend/app/lib/agentProvider.ts), so `AGENT_BACKEND=mock` drives the full +message UI (thinking header, todo timeline, step cards) without an LLM. +""" +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator + +from app.schemas.chat import ChatChunk, ChatRequest + +MOCK_INTRO = "已为您创建项目并生成待办任务,点击任务可查看执行进度。\n\n" + +MOCK_THOUGHT = ( + "先梳理用户意图,拆解成可执行的待办任务,再依次调用文件、终端、技能等工具," + "最后把产物写回工作区。" +) + +MOCK_TERMINAL = ( + "if ! command -v flutter &>/dev/null; then\n" + ' echo "===> installing Flutter..."\n' + " brew install --cask flutter\n" + "fi\n" +) + +# (task_id, label, [step meta, ...]) — one entry per frontend StepKind card. +_MOCK_TASKS: list[tuple[str, str, list[dict]]] = [ + ("1", "任务一:解析输入并制定计划", [{"kind": "tool_call", "name": "planner"}]), + ("2", "任务二:在终端安装依赖", [ + {"kind": "terminal", "code": MOCK_TERMINAL, "language": "shell"}, + ]), + ("3", "任务三:读写工作区文件", [ + {"kind": "file_read", "path": "docs/spec.md"}, + {"kind": "file_write", "path": "report/outline.md"}, + ]), + ("4", "任务四:加载技能并检索资料", [ + {"kind": "skill_load", "name": "writer"}, + {"kind": "browser", "title": "参考资料页面", "url": "https://example.com/report"}, + ]), + ("5", "任务五:产出交付物", [ + {"kind": "artifact", "name": "报告.pdf", "file_type": "pdf", "byte": 1310720}, + ]), +] + + +def _tokenize(text: str) -> list[str]: + """Tiny pseudo-tokenizer that keeps markdown roughly intact.""" + out: list[str] = [] + buf = "" + for ch in text: + buf += ch + if ch in " \n,.;!?,。;!?": + out.append(buf) + buf = "" + if buf: + out.append(buf) + return out + + +def _delta(chunk: ChatChunk) -> dict: + # Plain SSE `data:` frame (default `message` event); frontend routes on + # the payload's `type`, terminal frame is a chunk with type=="done". + return {"data": chunk.model_dump_json()} + + +class MockBackend: + def chat_stream(self, req: ChatRequest) -> AsyncIterator[dict]: + return self._mock_stream(req) + + def chat_attach(self, session_id: str) -> AsyncIterator[dict]: + # Mock turns are never in flight between requests: nothing to attach. + return self._mock_attach(session_id) + + async def _mock_attach(self, session_id: str) -> AsyncIterator[dict]: + yield _delta(ChatChunk(type="done", meta={"session_id": session_id})) + + async def _mock_stream(self, req: ChatRequest) -> AsyncIterator[dict]: + last_user = req.message.content if req.message else "" + + # 1. one reasoning block (drives the "thinking Ns" header). + yield _delta(ChatChunk( + type="thought", + content=f"用户意图:{last_user[:80]}\n{MOCK_THOUGHT}" if last_user else MOCK_THOUGHT, + meta={"duration": 6}, + )) + await asyncio.sleep(0.2) + + # 2. streamed markdown body. + for token in _tokenize(MOCK_INTRO): + yield _delta(ChatChunk(type="text", content=token)) + await asyncio.sleep(0.02) + + # 3. todo timeline: each task flips running -> done, with nested steps. + for task_id, label, steps in _MOCK_TASKS: + yield _delta(ChatChunk( + type="task", meta={"id": task_id, "label": label, "status": "running"})) + await asyncio.sleep(0.15) + for step in steps: + yield _delta(ChatChunk(type="step", meta={**step, "task_id": task_id})) + await asyncio.sleep(0.12) + yield _delta(ChatChunk( + type="task", meta={"id": task_id, "label": label, "status": "done"})) + + # 4. terminator. + yield {"data": ChatChunk( + type="done", meta={"session_id": req.session_id or "demo"} + ).model_dump_json()} diff --git a/webui/backend/app/backends/ms_agent/__init__.py b/webui/backend/app/backends/ms_agent/__init__.py new file mode 100644 index 000000000..df261c436 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/__init__.py @@ -0,0 +1 @@ +"""Real backend: adapters over the ms-agent SDK (consumed in-process).""" diff --git a/webui/backend/app/backends/ms_agent/agent_settings.py b/webui/backend/app/backends/ms_agent/agent_settings.py new file mode 100644 index 000000000..c276f4cc6 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/agent_settings.py @@ -0,0 +1,60 @@ +"""Agent-settings adapter — default model (ModelSettingsManager) + memory +defaults (PersonalizationSettings) + auto-attach masters (sidecar).""" +from __future__ import annotations + +from app.backends.ms_agent import model_link, sidecar +from app.backends.ms_agent.common import home +from app.backends.ms_agent.mapping import decode_model_id, encode_model_id +from app.backends.ms_agent.settings_store import settings_lock +from app.schemas.agent_settings import AgentSettings + + +def _ps(): + from ms_agent.personalization import PersonalizationSettings + + return PersonalizationSettings(global_dir=home()) + + +def get_settings() -> AgentSettings: + # default_model_id is a base64 Model.id (provider+name), matching /api/models + # so the frontend can highlight the selected model. + with settings_lock(): + provider, model = model_link.active_model() + default_model_id = encode_model_id(provider, model) if (provider and model) else None + cfg = _ps().load() + backend = cfg.memory_backend if cfg.memory_backend in ("file", "vector") else "file" + return AgentSettings( + default_provider_id=provider, + default_model_id=default_model_id, + default_memory_enabled=bool(cfg.memory_enabled), + default_memory_backend=backend, + global_mcp_auto_attach=sidecar.get("agent_settings", "global_mcp_auto_attach", True), + global_skill_auto_attach=sidecar.get("agent_settings", "global_skill_auto_attach", True), + ) + + +def update_settings(body: AgentSettings) -> AgentSettings: + from ms_agent.personalization import PersonalizationConfig + + with settings_lock(): + # default_model_id arrives as a base64 Model.id; decode and point the active + # model (llm block + default_model + catalog) at it so chat actually uses it. + if body.default_model_id: + try: + provider, model = decode_model_id(body.default_model_id) + model_link.set_active_model(provider, model) + except Exception: + pass + + ps = _ps() + cur = ps.load() + ps.save( + PersonalizationConfig( + global_instruction=cur.global_instruction, # preserve + memory_enabled=body.default_memory_enabled, + memory_backend=body.default_memory_backend, + ) + ) + sidecar.put("agent_settings", "global_mcp_auto_attach", body.global_mcp_auto_attach) + sidecar.put("agent_settings", "global_skill_auto_attach", body.global_skill_auto_attach) + return get_settings() diff --git a/webui/backend/app/backends/ms_agent/backend.py b/webui/backend/app/backends/ms_agent/backend.py new file mode 100644 index 000000000..74fdf1c85 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/backend.py @@ -0,0 +1,22 @@ +"""MsAgentBackend facade — dispatches to the per-domain SDK adapters. + +Domain methods are added here as each endpoint is ported. Chat is stateful and +lives in the runtime/chat modules; the facade just forwards to it. +""" +from __future__ import annotations + +from collections.abc import AsyncIterator + +from app.schemas.chat import ChatRequest + + +class MsAgentBackend: + def chat_stream(self, req: ChatRequest) -> AsyncIterator[dict]: + from app.backends.ms_agent import chat + + return chat.stream(req) + + def chat_attach(self, session_id: str) -> AsyncIterator[dict]: + from app.backends.ms_agent import chat + + return chat.attach(session_id) diff --git a/webui/backend/app/backends/ms_agent/bootstrap.py b/webui/backend/app/backends/ms_agent/bootstrap.py new file mode 100644 index 000000000..0bfac1816 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/bootstrap.py @@ -0,0 +1,142 @@ +"""ms_agent backend startup: home/env, default project, LLM settings seed. + +Runs once at app boot (only when AGENT_BACKEND=ms_agent). Idempotent. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +from app.core.settings import settings + + +def bootstrap() -> None: + from app.backends.ms_agent.common import apply_home_env, home, pm + + from app.backends.ms_agent import model_link + + apply_home_env() + _export_env() + pm() # ProjectManager.__init__ ensures ~/.ms_agent/projects + default project + _seed_llm_settings(home()) + _seed_tools_settings(home()) + # Normalize the model link so the chat dropdown lists the active model and + # default_model is a full "provider/model" (see model_link). + model_link.ensure_link() + + +def _export_env() -> None: + """Push backend credentials into the process env the SDK / MCP servers read.""" + for key, value in { + "OPENAI_API_KEY": settings.openai_api_key, + "OPENAI_BASE_URL": settings.openai_base_url, + "EXA_API_KEY": settings.exa_api_key, + }.items(): + if value and not os.environ.get(key): + os.environ[key] = value + + +def _seed_llm_settings(home_dir: str) -> None: + """Write settings.json `llm` from env when absent, so ConfigResolver yields a + working model. Matches §3.1: llm.{provider,model,api_key,base_url}.""" + path = Path(home_dir) / "settings.json" + data: dict = {} + if path.exists(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + data = {} + if "llm" in data: + return # never overwrite an existing config (e.g. a shared ~/.ms_agent) + + model = settings.ms_agent_llm_model + if not model: + return # nothing to seed; rely on framework default + env credentials + provider = settings.ms_agent_llm_provider or "openai" + llm: dict = {"provider": provider, "model": model} + if settings.openai_api_key: + llm["api_key"] = settings.openai_api_key + if settings.openai_base_url: + llm["base_url"] = settings.openai_base_url + data["llm"] = llm + data.setdefault("default_model", f"{provider}/{model}") + + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, path) + + +# Builtin repo tools seeded into settings.json so they are default-enabled and +# toggleable through the standard multi-level config resolve (framework yaml -> +# settings.json -> project yaml -> session). Presence of a `tools.` key +# enables it; add `enabled: false` to disable (honored by the SDK ToolManager). +_DEFAULT_TOOLS: dict = { + "file_system": { + "mcp": False, + "include": ["read_file", "grep", "glob", "edit_file", "write_file"], + }, + "todo_list": {"mcp": False}, + # Shell/terminal only. implementation must be the SDK's "python_env" (a + # local Jupyter kernel, no Docker); "local"/"sandbox" route to the Docker + # CodeExecutionTool (needs ms-enclave). `include: [shell_executor]` exposes + # ONLY the terminal tool (not notebook/python/file_operation). Restricted + # permission gates shell_executor (not whitelisted) so every command asks. + "code_executor": { + "mcp": False, + "implementation": "python_env", + "include": ["shell_executor"], + }, + # web_search needs exa-py + EXA_API_KEY; present but off by default. + "web_search": {"mcp": False, "engine": "exa", "enabled": False}, +} + +# task_control has no WebUI rendering component yet; strip it from any home that +# was seeded with the earlier default so it isn't loaded (idempotent migration). +_DROP_TOOLS = ("task_control",) + + +def _seed_tools_settings(home_dir: str) -> None: + """Write the default builtin-tool config into settings.json when absent, so + the tools are on out of the box and users can flip `enabled` per tool. Also + prunes retired defaults (``_DROP_TOOLS``) from an existing config.""" + path = Path(home_dir) / "settings.json" + data: dict = {} + if path.exists(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + data = {} + changed = False + if "tools" not in data: + data["tools"] = dict(_DEFAULT_TOOLS) + changed = True + else: + tools = data["tools"] + # Migration: drop retired default tools (only when present) so an + # already-seeded home stops loading a component the UI can't render. + for name in _DROP_TOOLS: + if name in tools: + del tools[name] + changed = True + # Add newly-introduced default tools that this home predates (e.g. + # code_executor), without overwriting a user's existing tool configs. + for name, cfg in _DEFAULT_TOOLS.items(): + if name not in tools: + tools[name] = cfg + changed = True + # Enforce "terminal = shell only": a code_executor without an explicit + # include/exclude (i.e. still the un-customized default) is narrowed to + # shell_executor. A user's own include/exclude is left untouched. + ce = tools.get("code_executor") + if isinstance(ce, dict) and "include" not in ce and "exclude" not in ce: + ce["include"] = ["shell_executor"] + changed = True + if not changed: + return + + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, path) diff --git a/webui/backend/app/backends/ms_agent/chat.py b/webui/backend/app/backends/ms_agent/chat.py new file mode 100644 index 000000000..e1611d091 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/chat.py @@ -0,0 +1,1308 @@ +"""POST /api/chat over Route A: enqueue one user turn, stream its events. + +The frontend sends the full message list each turn; the agent owns history via +SessionLog, so we only forward the latest user message. Events map to the +frontend's ChatChunk contract (frontend/app/lib/agentProvider.ts).""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +import re +import time +from collections.abc import AsyncIterator + +from app.backends.ms_agent.common import ( + _is_cheap_title, + _is_default_name, + autoname_session, + find_session, + home, + resolve_project, + sm_for, +) +from app.backends.ms_agent import sidecar, titler +from app.backends.ms_agent.runtime import ( + DRIVER_DONE, + DRIVER_ERROR, + TURN_END, + registry, +) +from app.schemas.chat import ChatChunk, ChatRequest + +logger = logging.getLogger("app.ms_agent.chat") + + +def _content_parts(msg) -> tuple[str, list[str]]: + """Extract ``(plain_text, skill_ids)`` from a message whose ``content`` is + either a plain string or a configuration-style segment array + (``[{type: text|skill, ...}]``). Legacy ``msg.skills`` ids are merged in + for wire compatibility.""" + if msg is None: + return "", [] + content = msg.content or "" + if isinstance(content, str): + text, ids = content.strip(), [] + else: + text = " ".join( + s.text for s in content if s.type == "text" and s.text + ).strip() + # Candidates for the SDK's find_skill (slug / frontmatter name): the + # display name the composer sent resolves there; the WebUI asset id + # (``src::…``) is kept as a fallback candidate. + ids = [] + for s in content: + if s.type != "skill": + continue + for cand in (s.name, s.id): + if cand and cand not in ids: + ids.append(cand) + for sid in getattr(msg, "skills", None) or []: # deprecated field + if sid and sid not in ids: + ids.append(sid) + return text, ids + + +def _compose_prompt(msg) -> str: + """Build the prompt enqueued to the agent for one user turn. + + When the user attached files, append a plainly-delimited block listing each + file's workspace-relative path (``user_files/``). The agent's file + tools are scoped to the project workspace, so these paths resolve to the + real uploaded bytes on disk. The block is part of the enqueued prompt, so it + persists into the SessionLog user message — that record is the durable + session↔files mapping (and replays as text in history). + """ + if msg is None: + return "" + text, _ = _content_parts(msg) + files = getattr(msg, "files", None) or [] + if not files: + return text + lines = [ + "[Attached files] (paths are relative to the project workspace root; " + "use the file tools to read them):" + ] + for f in files: + lines.append(f"- {f.path}") + block = "\n".join(lines) + return f"{text}\n\n{block}" if text else block + + +def _resolve_or_create(req: ChatRequest): + """Return (project, session). Reuse an existing session by id; otherwise + create one under the requested project (default project when unspecified).""" + if req.session_id: + found = find_session(req.session_id) + if found: + project, session, _sm = found + return project, session + try: + project = resolve_project(req.project_id) + except KeyError: + project = resolve_project(None) # unknown id -> default project + session = sm_for(project).create() + return project, session + + +def _delta(chunk: ChatChunk) -> dict: + # Emit a plain SSE `data:` frame (default `message` event). The frontend + # routes purely on the payload's `type`, so no custom `event:` name is + # needed; the terminal frame is just a chunk with type=="done". + return {"data": chunk.model_dump_json()} + + +def _turn_frame(rt) -> dict: + """A `turn` frame carrying how long the RUNNING turn has been going. + + The turn origin lives on the runtime (set when the prompt is enqueued), so + it is the same clock for the original stream and for a later re-attach — a + client that joins (or reloads) mid-turn learns the real elapsed time + instead of restarting its counter from zero. + """ + origin = getattr(rt, "turn_started_at", None) + elapsed = int((time.monotonic() - origin) * 1000) if origin else 0 + return _delta(ChatChunk(type="turn", meta={"elapsed_ms": elapsed})) + + +# Re-send the turn age at most this often (seconds) while frames flow, so the +# client's ticking counter is re-based against the server clock (second-level +# drift correction) without flooding the stream. +_TURN_SYNC_INTERVAL = 5.0 + + +# Plan (todo) status -> frontend task status (agentProvider.ts TaskStatus). +_PLAN_STATUS = {"pending": "pending", "in_progress": "running", "completed": "done"} +# Candidate argument keys carrying a file path, for nicer file_read/file_write cards. +_PATH_KEYS = ("path", "file_path", "target_file", "filename", "file") + + +def _as_dict(args) -> dict: + """Tool arguments arrive as a dict or a raw JSON string; normalize to a dict.""" + if isinstance(args, str): + try: + args = json.loads(args) + except (ValueError, TypeError): + return {} + return args if isinstance(args, dict) else {} + + +def _stringify(value) -> str: + """Render a tool result for display: pass strings through, JSON-encode + dicts/lists, else str().""" + if isinstance(value, str): + return value + try: + return json.dumps(value, ensure_ascii=False, indent=2) + except (TypeError, ValueError): + return str(value) + + +# Framework-generated assistant placeholder contents (SDK-side). They exist only +# to keep a content-less assistant message protocol-valid — NOT model output, so +# they must never surface in the UI. New logs are self-describing (the SDK flags +# synthetic filler with ``content_placeholder``, and no longer fills a tool-call +# turn at all); these literals are the OLD-log fallback. Mirrors +# ms_agent.agent.llm_agent (INTERRUPTED_PLACEHOLDER + the removed tool-call +# placeholder); kept webui-local to avoid importing SDK internals. +_INTERRUPTED_PLACEHOLDER = "[interrupted]" +_TOOLCALL_PLACEHOLDER = "Let me do a tool calling." + + +def is_placeholder_content(row: dict) -> bool: + """True when this row's ``content`` is framework filler, not model output. + + Structural first: ``content_placeholder`` (set by the SDK seal and by the + webui fallback seal) is authoritative and applies to ANY row shape. + + The literal comparison is only a fallback for logs written before that flag + existed, and is deliberately narrowed by corroborating structure so a + GENUINE reply that happens to equal a literal is never hidden: + + - ``[interrupted]`` counts only on a row flagged ``interrupted``; + - ``Let me do a tool calling.`` counts only on a row that carries + ``tool_calls`` (the only situation the SDK ever injected it). + + Non-string content (multimodal block lists) is never filler — and must not + be compared against a string set, which would raise TypeError on an + unhashable value and 500 the whole history endpoint. + """ + if row.get("content_placeholder"): + return True + content = row.get("content") + if not isinstance(content, str): + return False + if content == _INTERRUPTED_PLACEHOLDER: + return bool(row.get("interrupted")) + if content == _TOOLCALL_PLACEHOLDER: + return bool(row.get("tool_calls")) + return False + + +# Built-in tool servers (the SDK's own `server---tool` namespaces). Anything +# else with a `---` separator is an MCP server — the UI labels those "call +# MCP" instead of "call tool" (meta.source below). +_BUILTIN_SERVERS = { + "todo_list", "task_control", "file_system", "skills", "code_executor", + "web_search", "unified_memory", "agent_tools", +} + + +def _tool_source(name: str) -> str: + """``mcp`` for non-builtin `server---tool` names, else ``tool``.""" + base, sep, _ = name.partition("---") + return "mcp" if (sep and base not in _BUILTIN_SERVERS) else "tool" + + +def _tool_step_meta(name: str, args: dict) -> dict | None: + """Map one SDK tool call (`server---tool`) to a frontend `step` meta, or None + to drop it. Matching is on the full name (server + leaf), never the short + leaf alone, per the tool taxonomy. + + - todo_list / task_control -> None (plan machinery; shown as the task list). + - file_system read -> file_read (with a path). + - file_system write_file -> file_write; edit_file -> file_edit (distinct + cards: a full-content write vs an in-place edit render differently). + - file_system grep/glob -> search (a workspace query). + - skills -> skill_load. + - code_executor shell/py/nb -> terminal (command / code body). + - web_search *_search -> search (a web query); fetch_page -> browser. + - unified_memory -> memory (add/replace/remove/read). + - anything else (MCP, agent_tools, media, ...) -> the generic tool_call card, + rendered by its unified tool name. + """ + base, _, leaf = name.partition("---") + if base in ("todo_list", "task_control"): + return None + if base == "file_system": + if leaf in ("grep", "glob"): + query = str(args.get("pattern") or args.get("glob") or args.get("path") or "") + return {"kind": "search", "name": name, "query": query, "scope": "files"} + path = next( + (args[k] for k in _PATH_KEYS if isinstance(args.get(k), str) and args[k]), + "", + ) + if not path and isinstance(args.get("paths"), list): + path = ", ".join(str(p) for p in args["paths"][:3] if p) + if path and leaf == "read_file": + return {"kind": "file_read", "path": path, "name": name} + if path and leaf == "write_file": + return {"kind": "file_write", "path": path, "name": name} + if path and leaf == "edit_file": + return {"kind": "file_edit", "path": path, "name": name} + if base == "skills": + return {"kind": "skill_load", "name": args.get("skill_id") or leaf or name} + if base == "code_executor": + if leaf == "shell_executor": + return {"kind": "terminal", "name": name, "code": str(args.get("command") or "")} + if leaf in ("python_executor", "notebook_executor"): + return {"kind": "terminal", "name": name, "code": str(args.get("code") or "")} + if base == "web_search": + if leaf == "fetch_page": + return {"kind": "browser", "name": name, "url": str(args.get("url") or "")} + return {"kind": "search", "name": name, "query": str(args.get("query") or ""), "scope": "web"} + if base == "unified_memory": + action = str(args.get("action") or ("read" if leaf == "memory_read" else "")) + return {"kind": "memory", "name": name, "action": action} + return {"kind": "tool_call", "name": name, "source": _tool_source(name)} + + +class _TurnMapper: + """Assemble the frontend's ordered-parts view-model (agentProvider.ts) from + the SDK's flat semantic event stream. Stateful per turn: streams reasoning as + incremental `thought` frames (a final frame carries meta.duration to close the + block). Tool calls emit standalone `step` frames in stream order; a todo plan + (plan_updated) emits `task` frames rendered as a plain plan list — steps are + no longer nested under tasks.""" + + def __init__(self, session_id: str = "", resolved_permissions: list[dict] | None = None) -> None: + self._session_id = session_id + # Pre-resolved permissions (for attach replay only). Each entry has + # {tool_name, arguments, state}. Consumed in order (FIFO per tool_name). + self._resolved_perms: list[dict] = list(resolved_permissions or []) + self._reason_start: float | None = None + # call_id -> (name, args, group). A tool step is deferred to + # tool_call_completed (where ok/error is known), but the args + round + # group live on tool_call_started. + self._pending: dict[str, tuple[str, dict, int]] = {} + # Tool-round grouping mirrors the assistant message's tool_calls ARRAY + # directly: the SDK emits the whole array as consecutive + # ToolCallStarted events BEFORE executing any of them, so "pending was + # empty" marks the array's first element — no boundary inference + # (content/reasoning heuristics) needed. + self._group_seq = 0 + # True once a todo_write / todo_render_md completed this turn: the + # session plan files changed, so the loop's changed-files summary + # includes the reserved "plan.md" marker (todo steps themselves emit + # no card — this flag is the only trace). + self.plan_touched = False + # Raw plan locations the todo tool reported this turn (todo_write's + # `plan_path`, todo_render_md's target) — resolved at loop end to keep + # plan files (any configured name) out of the changed-files summary. + self.plan_reports: list[str] = [] + # The LAST markdown target a todo_render_md produced this turn (raw, + # workspace-relative). Only a FALLBACK for the loop's `plan_file` — the + # canonical plan.md todo_write maintains is preferred (see _plan_md_path). + self.latest_plan_md_report: str | None = None + + def map(self, payload: dict) -> list[ChatChunk]: + """Translate one AgentEvent dict into zero or more ChatChunks.""" + t = payload.get("type") + # Event timestamps (stamped by the sink buffer) keep durations truthful + # when a late viewer replays the buffer: elapsed time is between the + # ORIGINAL events, not the replay instants. + ts = payload.get("_ts") + now = ts if isinstance(ts, (int, float)) else time.monotonic() + if t == "content_delta": + return [ChatChunk(type="text", content=payload.get("text", ""))] + if t == "reasoning_started": + self._reason_start = now + return [] + if t == "reasoning_delta": + # Stream reasoning incrementally; the frontend appends to the open + # thought block. Start the clock lazily if we missed the start event. + if self._reason_start is None: + self._reason_start = now + text = payload.get("text", "") + return [ChatChunk(type="thought", content=text)] if text else [] + if t == "reasoning_ended": + return self._close_thought(now) + if t == "plan_updated": + return self._tasks(payload.get("entries", [])) + if t == "tool_call_started": + # Stash name+args+group by call_id — the completed event lacks + # arguments. The SDK flushes one round's WHOLE tool_calls array as + # consecutive started events before running any tool, so an empty + # pending map here means "first element of a new array" → new group. + if not self._pending: + self._group_seq += 1 + call_id = str(payload.get("call_id") or "") + name = payload.get("name", "") or "" + args = _as_dict(payload.get("arguments")) + self._pending[call_id] = (name, args, self._group_seq) + # Emit a live "running" card NOW so a slow tool (e.g. web_search) + # shows immediate feedback instead of a blank gap; the completed + # event's step (same call_id) replaces it in place on the frontend. + # The card's final shape (ok/error + result) is authored on + # completion in _tool_step. + return self._tool_running_step(name, args, self._group_seq, call_id) + if t == "tool_call_completed": + return self._tool_step(payload) + if t == "permission_request": + return self._permission_step(payload) + if t == "error": + return [ChatChunk(type="error", meta={ + "message": payload.get("message", ""), + "recoverable": bool(payload.get("recoverable", False)), + })] + # user_message / turn_started / content_end / context_compacted -> none. + return [] + + def _tool_running_step(self, name: str, args: dict, group: int, + call_id: str) -> list[ChatChunk]: + """A live 'executing' card emitted on tool_call_started. The frontend + replaces it in place with the completed step (same call_id). Plan + machinery (todo_list / task_control) renders no card, so skip it here + too — its _tool_step_meta is None.""" + meta = _tool_step_meta(name, args) + if meta is None: + return [] + meta = {**meta, "tool": name, "arguments": args, "group": group, + "call_id": call_id, "status": "running"} + return [ChatChunk(type="step", meta=meta)] + + def _tool_step(self, payload: dict) -> list[ChatChunk]: + """Emit a finished tool call's step card, carrying its full invocation + (tool name + arguments + result) so the detail drawer can show it, plus + ok/error status. Plan-machinery tools (todo_list / task_control) skip.""" + call_id = str(payload.get("call_id") or "") + name, args, group = self._pending.pop( + call_id, + (payload.get("name", "") or "", {}, self._group_seq or 1), + ) + if name in ("todo_list---todo_write", "todo_list---todo_render_md") \ + and not payload.get("error"): + # The session plan files changed. Record the plan location the tool + # reported (todo_write's `plan_path`, todo_render_md's target) so + # loop end can keep plan files — under any configured name — out of + # the changed-files summary (and, as a fallback, locate `plan_file`). + self.plan_touched = True + self._note_plan_report(name, payload.get("result")) + meta = _tool_step_meta(name, args) + if meta is None: + return [] + # Full invocation, so the detail drawer can show arguments + result. + # call_id lets the frontend replace this call's live "running" card in + # place (instead of appending a duplicate). + meta = {**meta, "tool": name, "arguments": args, "group": group, + "call_id": call_id} + result = payload.get("result") + if result is not None: + meta["result"] = _stringify(result) + duration_s = payload.get("duration_s") + if isinstance(duration_s, (int, float)): + meta["duration_ms"] = int(duration_s * 1000) + if payload.get("error"): + meta = {**meta, "status": "error", "error": str(payload.get("error"))} + return [ChatChunk(type="step", meta=meta)] + + def _note_plan_report(self, name: str, result) -> None: + """Extract the plan file location a completed todo call reported and + stash it (raw, as the tool phrased it — resolved against the workspace + at loop end). ``todo_write`` returns JSON with ``plan_path``; + ``todo_render_md`` returns ``OK: rendered plan markdown to ``.""" + text = _stringify(result) if result is not None else "" + if not text: + return + if name == "todo_list---todo_render_md": + m = re.match(r"^OK: rendered plan markdown to (.+)$", text.strip()) + if m: + target = m.group(1).strip() + self.plan_reports.append(target) + self.latest_plan_md_report = target + return + # todo_write: JSON payload with a relative plan_path (the plan json). + try: + data = json.loads(text) + except (ValueError, TypeError): + return + pp = data.get("plan_path") if isinstance(data, dict) else None + if isinstance(pp, str) and pp: + self.plan_reports.append(pp) + + def _permission_step(self, payload: dict) -> list[ChatChunk]: + """Surface a restricted-mode ask (WebPermissionHandler emit) as an + authorization card. The turn is suspended on the handler's Future until + POST /api/chat/permission resolves it (or it times out to deny). + + During attach replay, the session log's resolved state is used so the + card shows approved/rejected instead of stale pending buttons.""" + tool = str(payload.get("tool_name") or "") + call_id = str(payload.get("call_id") or "") + args = _as_dict(payload.get("tool_args")) + preview = json.dumps(args, ensure_ascii=False) + if len(preview) > 160: + preview = preview[:160] + "…" + # Check if this permission was already resolved (attach replay). Prefer + # an exact call_id match, else fall back to tool_name FIFO (old buffers). + state = "pending" + idx = next( + (i for i, r in enumerate(self._resolved_perms) + if call_id and str(r.get("call_id") or "") == call_id), + None, + ) + if idx is None: + idx = next( + (i for i, r in enumerate(self._resolved_perms) + if not str(r.get("call_id") or "") and r.get("tool_name") == tool), + None, + ) + if idx is not None: + state = self._resolved_perms.pop(idx).get("state", "pending") + # The ask belongs to its tool's round — share that group id so the + # auth card renders inside the same nested accordion. + pend = self._pending.get(call_id) + group = pend[2] if pend else (self._group_seq or 1) + meta = { + "kind": "authorization", + "state": state, + "request_id": str(payload.get("request_id") or ""), + "call_id": call_id, + "session_id": self._session_id, + "tool_name": tool, + "arguments": args, + "desc": f"{tool} {preview}".strip(), + "group": group, + "source": _tool_source(tool), + } + return [ChatChunk(type="step", meta=meta)] + + def flush(self) -> list[ChatChunk]: + """Close an open thought block and emit any pending (interrupted) tool calls + when a turn ends before their tool_call_completed arrived.""" + chunks = self._close_thought(time.monotonic()) + # Emit deferred tool calls that never completed (turn was interrupted). + for call_id, (name, args, group) in list(self._pending.items()): + meta = _tool_step_meta(name, args) + if meta is None: + continue + meta["status"] = "error" + meta["error"] = "[Interrupted: tool execution was cancelled]" + meta["group"] = group + # Same call_id as the live "running" card so the frontend flips that + # card to the interrupted state in place (no duplicate). + meta["call_id"] = call_id + chunks.append(ChatChunk(type="step", meta=meta)) + self._pending.clear() + return chunks + + def _close_thought(self, end: float) -> list[ChatChunk]: + """Emit a zero-width `thought` frame carrying the elapsed duration, which + finalizes the current thought block on the frontend. No-op if no reasoning + was in flight.""" + start, self._reason_start = self._reason_start, None + if start is None: + return [] + return [ChatChunk(type="thought", content="", + meta={"duration": max(0, round(end - start))})] + + def _tasks(self, entries: list) -> list[ChatChunk]: + # Re-send the whole plan on every update, one `task` chunk per row with + # index-stable ids. The frontend folds one burst into a frozen plan + # SNAPSHOT block appended at that point in the stream; a repeated id + # signals the next burst, so the stable ids delimit snapshots. + out: list[ChatChunk] = [] + for i, entry in enumerate(entries): + entry = entry if isinstance(entry, dict) else {"content": str(entry)} + out.append(ChatChunk(type="task", meta={ + "id": str(i), + "label": entry.get("content", ""), + "status": _PLAN_STATUS.get(entry.get("status", "pending"), "pending"), + })) + return out + + +def _seal_interrupted_turn(rt) -> None: + """Fallback seal: close the log if an aborted turn somehow left it open. + + The SDK now faithfully persists an interrupted round itself (run_loop's + cancellation handler seals partial content + synthesized tool results, all + flagged ``interrupted``), so normally the log already ends on an assistant + row when this runs. This guard only fires when that persistence could not + run (cancel before the round started, an older SDK, or a persist failure): + a dangling ``user``/``tool`` tail would make the rebuilt agent re-answer the + cancelled turn instead of the user's next message. The placeholder matches + the SDK's neutral marker — UIs render the ``interrupted`` flag, never the + literal content. + """ + agent = getattr(rt, "agent", None) + log = getattr(agent, "session_log", None) + if log is None: + return + try: + msgs = log.get_all_messages() + if msgs and msgs[-1].get("role") in ("user", "tool"): + log.append({ + "role": "assistant", + "content": _INTERRUPTED_PLACEHOLDER, + # Structured marker (mirrors the SDK seal): the content is + # synthetic filler, so replay renders the interrupted badge and + # hides the literal — no string-matching needed downstream. + "content_placeholder": True, + "interrupted": True, + }) + except Exception: + logger.debug("seal interrupted turn skipped", exc_info=True) + # A cancelled turn never reaches loop_end, so nothing would record its + # wall-clock duration — on replay the "processing Ns" header had no number + # to show and fell back to 0s (history carries no turn origin). Record the + # boundary here with the elapsed time up to the stop, so a reloaded page + # shows the same duration the live view froze at. + session_id = getattr(getattr(rt, "session", None), "id", "") + _persist_loop_end_from_log(rt, session_id) + + +async def _drain_abandoned_turn(rt, pos: int, session_id: str) -> None: + """The client left mid-turn WITHOUT an explicit stop (navigated to another + conversation). Keep this turn running in the background rather than cancel + it: the SDK driver keeps generating and persists each round to the + SessionLog, and the sink buffers the turn's events so a viewer can + re-attach later. This task just waits (from the leaver's cursor) for the + turn boundary, then releases the lock. + + This is deliberately not a cancel. Leaving a conversation — by navigation, + refresh, or even closing the browser — must let it finish (sessions run + concurrently on their own runtimes). The ONLY early stop is the explicit + POST /api/chat/interrupt. A *next* message to THIS session correctly waits + on the turn lock. + """ + try: + while True: + payload, pos = await rt.sink.next_event(pos) + if payload.get("type") in (TURN_END, DRIVER_DONE, DRIVER_ERROR): + break + # No live viewer will write the loop_end marker for this turn, so do it + # here: duration from the shared runtime turn origin, changed files + # derived from the session log (scoped to this turn). + _persist_loop_end_from_log(rt, session_id) + except Exception: # never let background cleanup crash + logger.debug("drain abandoned turn ended", exc_info=True) + finally: + if rt.turn_lock.locked(): + rt.turn_lock.release() + + +def _ws_root(rt) -> str | None: + """The runtime's workspace root (``project.path`` — where file-tool + relative paths resolve). None when unavailable.""" + try: + p = str(getattr(getattr(rt, "project", None), "path", "") or "") + return os.path.normpath(p) if p else None + except Exception: + return None + + +def _plan_md_path(rt, rendered_target: str | None = None) -> str | None: + """Absolute path of THE session plan markdown — the pointer the plan chip + labels itself with, kept consistent with the plan ``GET /sessions/{id}/plan`` + actually serves. + + Prefer the CANONICAL configured ``plan_md_filename``: with ``auto_render_md`` + on (the default), ``todo_write`` re-renders this file on EVERY plan change, + so it always mirrors the live ``plan.json`` the chip's popover reads (webui + points it at ``/plan.md``; an absolute value wins the join, so + an unusual configured location is honored too). A ``todo_render_md`` is an + explicit, optional export the model may aim anywhere under any name — it is + a copy, NOT necessarily the canonical plan — so its target is only a + fallback here, used when the configured path can't be resolved (e.g. + ``auto_render_md`` disabled with no config). None when nothing resolves. + + (The filtering set ``sessions.plan_paths_in_rows`` still collects ALL plan + locations — including every render target — so custom-named renders stay + out of the file ledgers regardless of this pointer choice.)""" + try: + cfg = getattr(rt.agent, "config", None) + tool_cfg = getattr(getattr(cfg, "tools", None), "todo_list", None) + raw = str(getattr(tool_cfg, "plan_md_filename", "") or "plan.md") + base = str(getattr(cfg, "output_dir", "") or "") + if base or os.path.isabs(raw): + return os.path.join(base, raw) + except Exception: + pass + # Fallback: an explicit render target, when the canonical path is unresolvable. + if rendered_target: + try: + ws = _ws_root(rt) + if ws or os.path.isabs(rendered_target): + return os.path.normpath(os.path.join(ws or "", rendered_target)) + except Exception: + pass + return None + + +def _persist_loop_end_from_log(rt, session_id: str) -> None: + """Record the loop_end marker for a turn that finished in the background + (no live observer). changed_files is derived from the current turn's rows + in the SessionLog; duration from the runtime turn origin.""" + try: + log = getattr(rt.agent, "session_log", None) + if log is None or not hasattr(log, "record_loop_end"): + return + from app.backends.ms_agent.sessions import ( + changed_files_in_rows, + latest_rendered_plan_md, + plan_paths_in_rows, + ) + + rows = log.get_all_messages() + # Scope to the last turn: assistant rows after the final user row. + last_user = max( + (i for i, r in enumerate(rows) if r.get("role") == "user"), + default=-1, + ) + # Idempotency: an explicit Stop drives BOTH the interrupt seal AND the + # aborted-SSE drain, and each lands here. Write at most one loop_end per + # turn — skip if this turn already has one (a loop_end whose seq is past + # the last user row). get_loop_ends() re-reads from disk, so it sees the + # other path's just-written marker; this fn is synchronous (no await) so + # the check-then-write can't interleave with the other task. + last_user_seq = rows[last_user].get("seq", -1) if last_user >= 0 else -1 + if hasattr(log, "get_loop_ends") and any( + le.get("seq", -1) > last_user_seq for le in log.get_loop_ends() + ): + return + turn_rows = rows[last_user + 1:] + ws = _ws_root(rt) + # Filter out-of-workspace writes + plan files (identified by the todo + # tool's own reports, so any configured plan filename is caught). + changed = changed_files_in_rows(turn_rows, ws) if ws else \ + changed_files_in_rows(turn_rows) + origin = getattr(rt, "turn_started_at", None) + duration_ms = int((time.monotonic() - origin) * 1000) if origin else 0 + payload = {"duration_ms": duration_ms, "changed_files": changed} + if "plan.md" in changed: + # Canonical plan.md (todo_write's); the latest render is a fallback. + rendered = latest_rendered_plan_md(turn_rows, ws) if ws else None + pf = _plan_md_path(rt, rendered) + if pf: + payload["plan_file"] = pf + log.record_loop_end(payload) + except Exception: + logger.debug("loop_end (drain) record skipped", exc_info=True) + + +# Upper bound on waiting for a session's turn lock. A healthy turn releases it +# in seconds; only a turn wedged on a dead endpoint holds it this long (the LLM +# client itself times out around 300s). On timeout we drop the wedged runtime +# and rebuild, so a stuck turn can't make later requests hang forever. +_LOCK_ACQUIRE_TIMEOUT = 330.0 + + +async def _acquire_live_runtime(project, session): + """Acquire a runtime turn lock, rebuilding if the driver died while waiting + or the lock stayed wedged past _LOCK_ACQUIRE_TIMEOUT.""" + while True: + rt = await registry.get(project, session) + try: + await asyncio.wait_for(rt.turn_lock.acquire(), timeout=_LOCK_ACQUIRE_TIMEOUT) + except asyncio.TimeoutError: + await registry.close(session.id) # drop the wedged runtime; rebuild next loop + continue + if not rt.run_task.done(): + return rt + rt.turn_lock.release() + + +def _catalog_for(rt): + """The runtime agent's SkillCatalog — the driver's own once prepare_skills + ran, else one built from the resolved config (timing-independent: a + freshly-built runtime hasn't reached prepare_skills yet).""" + agent = getattr(rt, "agent", None) + if agent is None: + return None + catalog = getattr(agent, "_skill_catalog", None) + if catalog is not None: + return catalog + from ms_agent.skill.catalog import SkillCatalog + + skills_config = getattr(getattr(agent, "config", None), "skills", None) + if not skills_config: + return None + catalog = SkillCatalog(config=skills_config) + catalog.load_from_config(skills_config) + return catalog + + +def _sync_runtime_skills(rt, project) -> None: + """Turn-boundary skill sync for a live session runtime. + + A long-lived agent's catalog is built once at prepare_skills; skills.json + edits and live-tree drops made after that (UI toggles, newly added or + deleted skills) would otherwise stay invisible until the runtime is + rebuilt. Called with the turn lock held (driver idle between turns): + re-merge the managed skill layer into the agent's config (replayable — + managed entries are origin-tagged and replaced wholesale) and resync the + catalog in place. The version bump makes the SDK's per-round + maybe_refresh_system_prompt() rebuild the system prompt only when the + skill surface actually changed. Best-effort: on failure the turn runs + with the previous catalog. + """ + agent = getattr(rt, "agent", None) + skill_runtime = getattr(agent, "_skill_runtime", None) if agent else None + if skill_runtime is None: + return # driver not through prepare_skills yet — it reads fresh config + try: + from ms_agent.tui.managed_config import merge_skills_into_config + + merge_skills_into_config(agent.config, home(), project.path) + skill_runtime.sync_with_config(agent.config.skills) + except Exception: + logger.debug("turn-boundary skill sync failed", exc_info=True) + + +def _strip_skill_token(text: str, skill) -> str: + """Remove the first whitespace-delimited ``/``/``/`` token for + the invoked skill, so the rest of the message becomes the arguments.""" + import re + + for candidate in (getattr(skill, "skill_id", ""), getattr(skill, "name", "")): + if not candidate: + continue + pattern = re.compile( + r"(?:(?<=\s)|^)/" + re.escape(candidate) + r"(?=\s|$)", + re.IGNORECASE, + ) + stripped, n = pattern.subn(" ", text, count=1) + if n: + # Mend the seam only (collapse doubled spaces/tabs); newlines and + # the rest of the message stay untouched. + return re.sub(r"[ \t]{2,}", " ", stripped).strip() + return text.strip() + + +def _expand_skill_request(rt, prompt: str, + skill_ids: list[str]) -> tuple[str, str, dict | None] | None: + """Two-tier slash-skill expansion (Route A runs no command router, so it + happens here before enqueuing). + + Tier 1 — structured: the composer sent the picked ``skills`` ids; expand + the first one with args = the prompt minus its ``/token`` (position-free, + parse-free). Tier 2 — free text: scan for the first whitespace-delimited + ``/token`` anywhere in the prompt that matches a catalog skill (covers + hand-typed and API input; unknown ``/x`` falls through as plain text). + + Returns ``(kind, content, marker)`` where kind ∈ {"submit", "message"} and + ``marker`` is the display-only skill-invocation record (submit only), or + None when nothing expanded. Never raises. + """ + from ms_agent.command.skill_bridge import ( + expand_skill, + expand_slash_text, + find_skill, + ) + from ms_agent.command.types import CommandResultType + + try: + catalog = _catalog_for(rt) + if catalog is None: + return None + result = None + invoked = None + for sid in skill_ids: # tier 1: first resolvable picked skill wins + skill = find_skill(catalog, sid) + if skill is None: + continue + invoked = skill + result = expand_skill( + catalog, sid, _strip_skill_token(prompt, skill)) + break + if result is None: + result = expand_slash_text(catalog, prompt) # tier 2 + if result is not None: + # Recover which skill matched (first known token) for the marker. + import re as _re + + for m in _re.finditer(r"(?:(?<=\s)|^)/([\w.-]+)(?=\s|$)", prompt): + skill = find_skill(catalog, m.group(1)) + if skill is not None: + invoked = skill + break + except Exception: # a broken skill must never break the chat + logger.debug("skill expand failed; sending text verbatim", exc_info=True) + return None + if result is None: + return None + if result.type == CommandResultType.SUBMIT_PROMPT: + # A composer skill pick with no typed text leaves prompt empty; fall + # back to the slash form so history replay shows a readable bubble. + shown = prompt or ( + f"/{invoked.skill_id}" if getattr(invoked, "skill_id", "") else prompt + ) + marker = { + "original_text": shown, + "skill_ids": [getattr(invoked, "skill_id", "")] if invoked else [], + } + return ("submit", result.content or prompt, marker) + if result.type == CommandResultType.MESSAGE: + return ("message", result.content or "", None) + return None # MUTATE_STATE / QUIT are not modeled over the SSE turn (v1) + + +async def _apply_title(project, session_id: str, text: str) -> dict | None: + """Generate an agent title + topic category for a session's first message and + persist them (session name + ``category`` sidecar). Returns the applied + ``{title, category}`` for the ``done`` frame, or None when generation failed + (the cheap first-line title from ``autoname_session`` then stands).""" + res = await titler.generate_title_and_category(text) + if not res: + return None + title, category = res + try: + sm_for(project).update(session_id, name=title) + except Exception: # naming is best-effort; keep the fallback title + logger.debug("title update failed", exc_info=True) + try: + sidecar.merge("sessions", session_id, {"category": category}) + except Exception: + logger.debug("category persist failed", exc_info=True) + return {"title": title, "category": category} + + +async def stream(req: ChatRequest) -> AsyncIterator[dict]: + project, session = _resolve_or_create(req) + session_id = session.id + + user_msg = req.message + prompt = _compose_prompt(user_msg) + # A bare skill pick (pill only, no text) is a valid submission — the skill + # expansion below submits the skill body as the turn. Only truly empty + # input returns. + typed_text, picked_skill_ids = _content_parts(user_msg) + if not prompt and not picked_skill_ids: + done = ChatChunk( + type="done", + meta={"session_id": session_id, "project_id": project.id}, + ) + yield {"data": done.model_dump_json()} + return + + # Name a new session: set a cheap first-line title immediately (so the list + # updates without waiting), then, for a brand-new session, kick off an + # agent-generated title + topic category concurrently with the turn. The + # result is folded into the terminal `done` frame so the frontend refreshes + # the conversation lists with the summarized title and category icon. + # A bare skill pick has no typed text — seed with the slash form so the + # cheap title / titler don't fall back to the expanded wrapper text. + first_text = ( + typed_text + or prompt + or (f"/{picked_skill_ids[0]}" if picked_skill_ids else "") + ) + # "Needs a real title" covers both the SDK default name AND a cheap + # first-message slice (the project-page path pre-seeds title=text[:60] at + # createSession, which otherwise permanently suppressed the LLM titler). + needs_title = _is_default_name(session.name) or _is_cheap_title( + session.name, first_text + ) + autoname_session(project, session, first_text) + title_task = ( + asyncio.create_task(_apply_title(project, session_id, first_text)) + if needs_title + else None + ) + + # The session now exists on disk (created by _resolve_or_create, given a + # cheap first-line title). Announce it immediately so the client refreshes + # its conversation lists right away — without waiting for the whole turn or + # the agent title. The `done` frame later carries the summarized title + + # category for a second refresh and the URL redirect. + yield _delta(ChatChunk( + type="session", + meta={"session_id": session_id, "project_id": project.id}, + )) + + # Acquire the turn lock FIRST, then sync skills and expand slash + # invocations against the freshly-synced catalog. With the lock held the + # driver is idle between turns, so the in-place catalog resync cannot race + # a running generation, and the expansion sees mid-session skill changes + # (the pre-sync ordering is why a newly added skill is now invocable in + # the same session). `started` guards the lock across the pre-enqueue + # yields: an intro-only reply or a client disconnect must release it. + rt = await _acquire_live_runtime(project, session) + marker: dict | None = None + started = False + try: + _sync_runtime_skills(rt, project) + + # Skill-update notice (tail-only sync): compare the freshly-synced + # catalog against what this session was last told (skill_surface.json) + # and, on drift, prefix this turn's prompt with a + # carrying the full current list. The sidecar is committed only after + # the turn is actually enqueued — an intro-only reply or a failed + # enqueue re-fires the notice next turn instead of losing it. + notice: str | None = None + commit_notice = None + catalog0 = _catalog_for(rt) + if catalog0 is not None: + try: + from app.backends.ms_agent.skill_notice import pending_notice + + notice, commit_notice = pending_notice(catalog0, project, session) + except Exception: + logger.debug("skill notice check failed", exc_info=True) + user_typed = prompt # pre-expansion text, for the display marker + + # Slash-skill invocation, two tiers (Route A doesn't route commands, + # so it happens here): the composer's structured `skills` ids first, + # else a scan for a whitespace-delimited /token anywhere in the text. + # Any invocation — with or without args — runs the enriched skill + # prompt as the turn (a bare /skill submits the skill body so the + # model reads it and acts); a display-only marker keeps the user's + # ORIGINAL text for history replay. The "message" branch below stays + # as a fallback for other CommandResult kinds. + skill_ids = _content_parts(user_msg)[1] + if skill_ids or "/" in prompt: + expanded = _expand_skill_request(rt, prompt, skill_ids) + if expanded is not None: + kind, content, marker = expanded + if kind == "message": + if content: + yield _delta(ChatChunk(type="text", content=content)) + done = ChatChunk( + type="done", + meta={"session_id": session_id, "project_id": project.id}, + ) + yield {"data": done.model_dump_json()} + return + prompt = content # "submit": run the enriched skill prompt as the turn + + if notice: + # Prepend AFTER expansion so token stripping never sees the + # notice. The model reads the notice; the UI shows the typed text + # via the display marker (same mechanism as slash expansion). + prompt = notice + "\n\n" + prompt + if marker is None: + marker = {"original_text": user_typed, "skill_ids": []} + + if marker is not None and isinstance( + getattr(user_msg, "content", None), list + ): + # Persist the configuration-style segments AS SENT (skill id + + # display name + text) so history replays exactly what the user + # saw in the composer (pills with readable names, not raw ids). + marker["segments"] = [s.model_dump() for s in user_msg.content] + elif marker is None and isinstance( + getattr(user_msg, "content", None), list + ) and any(s.type == "skill" for s in user_msg.content): + # Skill pills were sent but nothing expanded (e.g. the skill was + # deleted): still persist a display marker so the echo keeps the + # pills instead of silently dropping them. + marker = { + "original_text": user_typed, + "skill_ids": [], + "segments": [s.model_dump() for s in user_msg.content], + } + + completed = False + rt.sink.new_turn() # this turn owns the event buffer from here + await rt.enqueue(prompt, marker=marker) + # Turn wall-clock origin on the runtime, so whoever observes the turn's + # end (this live stream OR the background drain) records the same + # loop_end duration regardless of when the client left. + rt.turn_started_at = time.monotonic() + # Wall-clock twin of the origin, for comparisons against file mtimes + # (e.g. "was plan.json written during THIS turn" in sessions.read_plan). + rt.turn_started_wall = time.time() + rt.watchers += 1 # a live SSE consumer is streaming this turn + started = True + if notice and commit_notice is not None: + commit_notice() # the model has now been told — persist the surface + finally: + if not started and rt.turn_lock.locked(): + rt.turn_lock.release() + usage = None + error_sent = False + mapper = _TurnMapper(session_id) + pos = 0 + # This turn = one tool-call loop. Track wall-clock + files written/edited so + # the terminal `done` frame can carry a loop summary (frontend collapses the + # intermediate steps into a "done · Ns" header listing what changed). The + # history-replay counterpart is SessionMessage.changed_files. + loop_start = time.monotonic() + changed_live: list[str] = [] + changed_seen: set[str] = set() + + ws = _ws_root(rt) + + def _collect_changed(chunks: list) -> None: + # Keep RAW write/edit paths (as the tool phrased them); classification + # into workspace-relative deliverables happens at _changed_files() time, + # once all of this turn's plan reports are known. + for c in chunks: + meta = getattr(c, "meta", None) or {} + if c.type == "step" and meta.get("kind") in ("file_write", "file_edit"): + p = str(meta.get("path") or "") + if p and p not in changed_seen: + changed_seen.add(p) + changed_live.append(p) + + def _changed_files() -> list[str]: + # Workspace deliverables written/edited this loop, plus the reserved + # "plan.md" marker when the todo plan changed. A write is a deliverable + # only if it resolves INSIDE the workspace and isn't a plan file — the + # model may copy its plan into the session dir (via ``..``/absolute) or + # render it under any name; those are plan/session state, surfaced by + # the plan chip (content via GET /sessions/{id}/plan), not file cards. + from app.backends.ms_agent.sessions import ( + _resolve_tool_path, + _workspace_rel, + ) + + files: list[str] = [] + if ws: + plan_abs = { + _resolve_tool_path(ws, r) for r in mapper.plan_reports + } + for p in changed_live: + ap = _resolve_tool_path(ws, p) + if ap in plan_abs: + continue + rel = _workspace_rel(ws, ap) + if rel is not None: + files.append(rel) + else: + files = list(changed_live) + if mapper.plan_touched and "plan.md" not in files: + files.append("plan.md") + return files + + def _loop_duration_ms() -> int: + # Prefer the runtime turn origin (shared with the background drain), so + # live and replay report the same duration; fall back to this stream's + # observation start. + origin = getattr(rt, "turn_started_at", None) or loop_start + return int((time.monotonic() - origin) * 1000) + + def _loop_meta() -> dict: + # One summary shape for both the `done` frame and the persisted + # loop_end marker. When the turn touched the todo list, `plan_file` + # carries the plan markdown's ABSOLUTE path (session dir under webui + # config) so the frontend can key the plan chip without an + # exists-in-workspace check ("plan.md" in changed_files is the marker, + # plan_file the location; content via GET /sessions/{id}/plan). + meta = { + "changed_files": _changed_files(), + "duration_ms": _loop_duration_ms(), + } + if mapper.plan_touched: + # The canonical plan.md todo_write maintains (consistent with + # GET /plan); the latest todo_render_md target is only a fallback. + pf = _plan_md_path(rt, mapper.latest_plan_md_report) + if pf: + meta["plan_file"] = pf + return meta + + def _persist_loop_end() -> None: + # Durable loop boundary so history replay can reproduce the "done · Ns" + # summary (duration is not derivable from message rows). Best-effort; + # written once per turn by whoever observes its end. + try: + log = getattr(rt.agent, "session_log", None) + if log is not None and hasattr(log, "record_loop_end"): + log.record_loop_end(_loop_meta()) + except Exception: + logger.debug("loop_end record skipped", exc_info=True) + + async def _title_meta() -> dict: + """Await the concurrent titling task (if any) and return its meta for the + `done` frame. Best-effort: an error/timeout just omits title+category.""" + if title_task is None: + return {} + try: + res = await title_task + except Exception: + return {} + return {"title": res["title"], "category": res["category"]} if res else {} + + try: + # Turn age first: the client bases its "processing Ns" counter on the + # server clock from frame one (and re-bases on the periodic re-send). + yield _turn_frame(rt) + last_turn_sync = time.monotonic() + while True: + payload, pos = await rt.sink.next_event(pos) + t = payload.get("type") + + if t in (TURN_END, DRIVER_DONE): + flushed = mapper.flush() + _collect_changed(flushed) + for chunk in flushed: + yield _delta(chunk) + _persist_loop_end() # durable loop boundary (before the done frame) + done = ChatChunk( + type="done", + meta={ + "session_id": session_id, + "project_id": project.id, + "usage": usage, + **_loop_meta(), + **(await _title_meta()), + }, + ) + completed = True + yield {"data": done.model_dump_json()} + return + if t == DRIVER_ERROR: + completed = True + if not error_sent: + yield _delta(ChatChunk(type="error", meta={ + "message": payload.get("message", ""), + "recoverable": bool(payload.get("recoverable", False)), + })) + _persist_loop_end() + done = ChatChunk( + type="done", + meta={ + "session_id": session_id, + "project_id": project.id, + **_loop_meta(), + **(await _title_meta()), + }, + ) + yield {"data": done.model_dump_json()} + return + if t == "turn_completed": + usage = payload.get("usage") # per-round; kept for the done frame + continue + + if t == "error": + error_sent = True + mapped = mapper.map(payload) + _collect_changed(mapped) + for chunk in mapped: + yield _delta(chunk) + if time.monotonic() - last_turn_sync >= _TURN_SYNC_INTERVAL: + yield _turn_frame(rt) # re-base the client's counter + last_turn_sync = time.monotonic() + finally: + rt.watchers -= 1 # this live consumer is gone (finished or left) + if completed: + if rt.turn_lock.locked(): + rt.turn_lock.release() + else: + # Client left mid-turn without an explicit stop (navigated away, + # refreshed, or closed the browser): keep the turn running in the + # background and release the lock at the turn boundary, so the + # conversation finishes and persists — and a viewer can re-attach + # via POST /api/chat/attach. Only POST /api/chat/interrupt cancels. + asyncio.create_task(_drain_abandoned_turn(rt, pos, session_id)) + + +async def attach(session_id: str) -> AsyncIterator[dict]: + """Re-attach a viewer to a session's in-flight turn (SSE). + + Replays the current turn's buffered events from the start (full catch-up: + thoughts with their real elapsed times, steps, text so far) and then + follows the live tail until the turn boundary — the same ChatChunk protocol + as POST /api/chat, so the frontend renders it identically. When no turn is + in flight, emits just a `done` frame (the viewer falls back to history). + """ + rt = registry.peek(session_id) + if rt is None or not registry.is_running(session_id): + done = ChatChunk(type="done", meta={"session_id": session_id}) + yield {"data": done.model_dump_json()} + return + + rt.watchers += 1 # a live viewer is streaming this turn again + usage = None + # Pre-scan the event buffer so replayed authorization cards show their REAL + # state instead of stale pending buttons. Truth sources (not heuristics): + # - the live handler's `_pending` futures: a request still awaiting the + # user replays as pending (buttons work — the Future is alive); + # - everything else was decided; the decision's approve/reject state comes + # from the persisted permission records (written at resolve time). + # The old "any event after the ask means approved" inference broke the + # common case of an APPROVED slow tool: no event follows while it runs, so + # a refresh replayed dead pending buttons whose clicks could only fail + # (the Future was long resolved → the card flipped to "rejected"). + resolved_perms: list[dict] = [] + try: + buf = list(rt.sink._events) + perm_events = [ + ev for ev in buf if ev.get('type') == 'permission_request' + ] + if perm_events: + handler = getattr(rt, 'permission_handler', None) + pending_ids = { + rid + for rid, fut in (getattr(handler, '_pending', None) or {}).items() + if not fut.done() + } + records: list[dict] = [] + try: + found = find_session(session_id) + if found: + _proj, _sess, sm = found + log = sm.get_session_log(_sess) + if hasattr(log, 'get_permissions'): + records = list(log.get_permissions()) + except Exception: + records = [] + for ev in perm_events: + rid = str(ev.get('request_id') or '') + if rid and rid in pending_ids: + continue # genuinely awaiting the user's decision + call_id = str(ev.get('call_id') or '') + tool = str(ev.get('tool_name') or '') + rec = next( + (r for r in records + if call_id and str(r.get('call_id') or '') == call_id), + None, + ) or next( + (r for r in records if r.get('tool_name') == tool), + None, + ) + resolved_perms.append({ + 'tool_name': tool, + 'call_id': call_id, + # A decided ask with no record yet (persistence raced) can + # only have been approved — a denial ends the call at once. + 'state': str((rec or {}).get('state') or 'approved'), + }) + except Exception: + pass + mapper = _TurnMapper(session_id, resolved_permissions=resolved_perms) + pos = 0 + try: + # The rejoining client (a reload, or a second viewer) has no idea when + # this turn started — tell it, before replaying anything, so its + # "processing Ns" counter continues instead of restarting at zero. + yield _turn_frame(rt) + last_turn_sync = time.monotonic() + while True: + payload, pos = await rt.sink.next_event(pos) + t = payload.get("type") + if t in (TURN_END, DRIVER_DONE, DRIVER_ERROR): + for chunk in mapper.flush(): + yield _delta(chunk) + if t == DRIVER_ERROR: + yield _delta(ChatChunk(type="error", meta={ + "message": payload.get("message", ""), + "recoverable": bool(payload.get("recoverable", False)), + })) + done = ChatChunk( + type="done", meta={"session_id": session_id, "usage": usage} + ) + yield {"data": done.model_dump_json()} + return + if t == "turn_completed": + usage = payload.get("usage") + continue + for chunk in mapper.map(payload): + yield _delta(chunk) + if time.monotonic() - last_turn_sync >= _TURN_SYNC_INTERVAL: + yield _turn_frame(rt) # re-base the client's counter + last_turn_sync = time.monotonic() + finally: + rt.watchers -= 1 diff --git a/webui/backend/app/backends/ms_agent/common.py b/webui/backend/app/backends/ms_agent/common.py new file mode 100644 index 000000000..d5607ff72 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/common.py @@ -0,0 +1,150 @@ +"""Shared SDK access helpers: home dir, project/session resolution. + +All ms_agent-backed adapters go through here so the global home and the +project/session lookups stay consistent (and honor MS_AGENT_HOME). +""" +from __future__ import annotations + +import os + +from app.core.settings import settings + + +def home() -> str: + """The SDK global home (~/.ms_agent unless MS_AGENT_HOME is set).""" + from ms_agent.project.paths import global_home + + return str(global_home()) + + +def apply_home_env() -> None: + """Bridge settings.ms_agent_home -> MS_AGENT_HOME so the SDK's global_home() + resolves to the WebUI home from any entry point. An explicitly-set + MS_AGENT_HOME (shell export / test isolation) always wins.""" + if settings.ms_agent_home and not os.environ.get("MS_AGENT_HOME"): + os.environ["MS_AGENT_HOME"] = os.path.expanduser(settings.ms_agent_home) + + +# Apply on import. This module is the single entry point for every ms_agent +# adapter, so merely importing it (server, CLI, script, test) pins MS_AGENT_HOME +# before any global_home() read — no more silent fallback to ~/.ms_agent when a +# script/CLI skips the app's boot path. +apply_home_env() + + +def pm(): + from ms_agent.project import ProjectManager + + return ProjectManager(base_dir=home()) + + +def sm_for(project): + from ms_agent.project import SessionManager + + return SessionManager(project) + + +def resolve_project(project_id: str | None): + """Return the Project for an id, falling back to the default project. + + Raises KeyError if a non-null id does not exist. + """ + manager = pm() + if not project_id: + return manager.get_default_project() + proj = manager.get(project_id) + if proj is None: + raise KeyError(f"project not found: {project_id}") + return proj + + +def _is_default_name(name: str) -> bool: + """SessionManager.create() names sessions 'Session <6hex>' by default.""" + return not name or name.startswith("Session ") + + +def _is_cheap_title(name: str, first_text: str) -> bool: + """True when ``name`` is just a leading slice of the first message — a + placeholder, not a real title. Both cheap-title writers produce prefixes: + the frontend seeds ``text.slice(0, 60)`` at createSession (the project-page + first-message path) and ``autoname_session`` uses line1[:40]. Without this + check those sessions never get an LLM title (``_is_default_name`` sees a + non-default name and skips the titler).""" + name = (name or "").strip() + if not name: + return True + head = (first_text or "").strip() + if not head: + return False + return head.startswith(name) or head.splitlines()[0].startswith(name) + + +def _title_from_text(text: str) -> str: + return text.strip().splitlines()[0][:40] if text and text.strip() else "" + + +def _first_user_line(project, session) -> str: + """Cheap: first line of the first user message in the session log.""" + import json + + from ms_agent.project.paths import global_projects_root + + path = (global_projects_root() / project.id / "sessions" / session.id + / f"{session.session_key}.jsonl") + if not path.exists(): + return "" + try: + with open(path, encoding="utf-8") as fh: + for line in fh: + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + if msg.get("role") == "user" and msg.get("content"): + return _title_from_text(str(msg["content"])) + except OSError: + return "" + return "" + + +def autoname_session(project, session, text: str | None = None): + """Name a still-default session after its first user message (like ChatGPT / + the TUI). Uses `text` when given (chat's new message), else derives from the + session log. Returns the (possibly updated) session.""" + if not _is_default_name(session.name): + return session + title = _title_from_text(text) if text else _first_user_line(project, session) + if not title: + return session + try: + return sm_for(project).update(session.id, name=title) + except Exception: + return session + + +def find_session(session_id: str): + """Locate a session by id across all projects. + + Sessions live at ~/.ms_agent/projects//sessions//session.json. + Returns (Project, Session, SessionManager) or None. + """ + from ms_agent.project.paths import global_projects_root + + root = global_projects_root() + if not root.exists(): + return None + manager = pm() + for entry in root.iterdir(): + if not entry.is_dir(): + continue + meta = entry / "sessions" / session_id / "session.json" + if not meta.exists(): + continue + project = manager.get(entry.name) + if project is None: + continue + sm = sm_for(project) + session = sm.get(session_id) + if session is not None: + return project, session, sm + return None diff --git a/webui/backend/app/backends/ms_agent/config.py b/webui/backend/app/backends/ms_agent/config.py new file mode 100644 index 000000000..e6449e6cf --- /dev/null +++ b/webui/backend/app/backends/ms_agent/config.py @@ -0,0 +1,521 @@ +"""Assemble the per-session run config and construct the LLMAgent (Route A). + +Mirrors ms_agent/tui/app.py: ConfigResolver.resolve() for the layered config +(framework defaults -> settings.json -> project patch -> session overrides), +then route-A shaping (interactive lifecycle, streaming, session-log dir), then +the managed MCP/skills bridge, then LLMAgent with the UI seams injected. +""" +from __future__ import annotations + +import logging +import os + +from app.backends.ms_agent.common import home + +logger = logging.getLogger("app.ms_agent.config") + + +def _apply_webui_defaults(config): + """Make a newly-created WebUI session useful without requiring a hand-written + agent.yaml. Project/global config can still override these keys.""" + from omegaconf import OmegaConf + + if OmegaConf.select(config, "skills", default=None) is None: + OmegaConf.update(config, "skills", {}, merge=True) + for key, value in { + "skills.prompt_injection": "all", + "skills.auto_discover": True, + "skills.enable_manage": False, + "skills.disabled": [], + # Skill changes are announced as in-conversation + # notices (chat._maybe_skill_notice); the SDK then keeps the system + # prompt byte-stable per session (head_refresh_enabled=False) so the + # provider prefix cache never breaks on a skill change. + "skills.update_notice": True, + }.items(): + if OmegaConf.select(config, key, default=None) is None: + OmegaConf.update(config, key, value, merge=True) + # Builtin repo tools live in settings.json's `tools` block and are merged by + # the SDK ConfigResolver (multi-level resolve); nothing to inject here. + return config + + +def _vector_memory_available() -> bool: + """The 'vector' project backend maps to the SDK's mem0 adapter (`mem0ai` + is a backend dependency; the guard keeps a broken install non-fatal).""" + try: + import mem0 # noqa: F401 + + return True + except Exception: + return False + + +# DashScope OpenAI-compatible embeddings (probed: 1024 dims). The embedder must +# come from a provider that actually serves /embeddings — the active chat model +# (e.g. DeepSeek) usually does not. +_EMBEDDER_MODEL = "text-embedding-v4" +_EMBEDDER_DIMS = 1024 + + +def _read_settings() -> dict: + import json + + try: + with open(os.path.join(home(), "settings.json"), encoding="utf-8") as fh: + return json.load(fh) or {} + except (OSError, json.JSONDecodeError): + return {} + + +def _mem0_options(project) -> dict | None: + """mem0 backend options for a 'vector' project. + + - embedder: the DashScope-compatible provider from settings.json + (dashscope/openai entries), falling back to OPENAI_* env. + - llm (fact extraction): the active chat model from settings.json.llm. + - vector_store: local on-disk qdrant under the project memory dir + (embedded mode — no server). + Returns None when no embeddings-capable credentials exist.""" + from ms_agent.project.paths import memory_dir + + s = _read_settings() + emb_creds = None + for pid in ("dashscope", "openai"): + p = (s.get("providers") or {}).get(pid) or {} + if p.get("api_key") and p.get("base_url"): + emb_creds = {"api_key": p["api_key"], "openai_base_url": p["base_url"]} + break + if emb_creds is None and os.environ.get("OPENAI_API_KEY") and os.environ.get("OPENAI_BASE_URL"): + emb_creds = { + "api_key": os.environ["OPENAI_API_KEY"], + "openai_base_url": os.environ["OPENAI_BASE_URL"], + } + if emb_creds is None: + return None + + options: dict = { + "embedder": { + "provider": "openai", + "config": { + **emb_creds, + "model": _EMBEDDER_MODEL, + "embedding_dims": _EMBEDDER_DIMS, + }, + }, + "vector_store": { + "provider": "qdrant", + "config": { + "path": str(memory_dir(project.path) / "qdrant"), + "on_disk": True, + "collection_name": "webui_memory", + "embedding_model_dims": _EMBEDDER_DIMS, + }, + }, + } + llm = s.get("llm") or {} + if llm.get("api_key") and llm.get("base_url") and llm.get("model"): + options["llm"] = { + "provider": "openai", + "config": { + "model": llm["model"], + "api_key": llm["api_key"], + "openai_base_url": llm["base_url"], + }, + } + return options + + +def _apply_webui_memory(config, project): + """Wire the project's memory toggle to the SDK's unified memory. + + Enabled -> `memory.unified_memory`, namespaced per project and rooted + under `/.ms_agent/memory/` (via output_dir): + - memory_backend "file" -> FileBasedBackend (MEMORY.md, default); + writes happen through the model's `memory` tool. + - memory_backend "vector" -> Mem0Backend (mem0 + local qdrant); writes + happen through mem0's per-round fact extraction, so the node also + activates the agent's `add_after_step` ingestion hook. + Falls back to file (with a warning) when mem0/embeddings are unavailable. + Disabled -> drop any lower-layer memory block so the WebUI toggle is + authoritative (no memory tools, no injection).""" + from omegaconf import OmegaConf + + if getattr(project, "memory_enabled", False): + pid = project.id or "default" + backend = getattr(project, "memory_backend", None) or "file" + node: dict = { + "storage": {"backend": "file"}, + "namespace": {"user_id": pid}, + # Legacy-shaped fields some agent paths read directly: + # SharedMemoryManager keying + add_memory()'s per-step ingestion + # (get_memory_meta_safe requires an explicit add_after_step block). + "user_id": pid, + "add_after_step": {"user_id": pid}, + } + if backend == "vector": + options = _mem0_options(project) if _vector_memory_available() else None + if options is not None: + node["storage"]["backend"] = "mem0" + node["mem0"] = options + else: + logger.warning( + "memory_backend 'vector' unavailable (mem0ai missing or no " + "embeddings provider); falling back to file for project %s", + pid, + ) + elif backend != "file": + logger.warning("unknown memory_backend %r; using file", backend) + OmegaConf.update(config, "memory.unified_memory", node, merge=True) + # The WebUI drives unified_memory only. Legacy memory types leaking in + # from project/global config (notably `default_memory`, whose mem0 v1 + # calls break against the mem0 2.x we ship) are dropped, not merged. + mem_node = OmegaConf.select(config, "memory", default=None) + for key in [k for k in (mem_node or {}) if k != "unified_memory"]: + logger.warning("dropping legacy memory type %r (webui uses unified_memory)", key) + del mem_node[key] + elif OmegaConf.select(config, "memory", default=None) is not None: + del config["memory"] + return config + + +# Read-only / plan-machinery tools that restricted mode lets through without a +# confirmation card. Writes (write_file/edit_file), shell (code_executor) and +# MCP tools are NOT whitelisted, so each surfaces an authorization card. +_PERMISSION_WHITELIST = [ + "file_system---read_file", + "file_system---grep", + "file_system---glob", + "todo_list---*", + "unified_memory---*", + "skills---*", +] + + +def _apply_webui_permission(config, project_mode: str | None = None): + """Default this phase to the SDK's restricted (ask) mode. + + Non-whitelisted tools suspend on the session's WebPermissionHandler, which + surfaces an authorization card over SSE and times out to deny. Explicit + `permission.*` from settings.json / project config still wins — we only + fill blanks. ``project_mode`` is the UI's per-project override (the + composer's restricted/full-access selector, stored in the project sidecar): + an explicit user choice, so it beats the fill-blanks default.""" + from omegaconf import OmegaConf + + if project_mode in ("restricted", "auto"): + OmegaConf.update(config, "permission.mode", project_mode, merge=True) + elif OmegaConf.select(config, "permission.mode", default=None) is None: + OmegaConf.update(config, "permission.mode", "restricted", merge=True) + if OmegaConf.select(config, "permission.whitelist", default=None) is None: + OmegaConf.update( + config, "permission.whitelist", list(_PERMISSION_WHITELIST), merge=True + ) + return config + + +def thinking_default(protocol: str, provider: str, model: str = "") -> bool: + """Whether ``extra_body.enable_thinking`` is ON by DEFAULT for this + provider/model/protocol, before any user override. Single source of truth: + used by build shaping (``_apply_model_compatibility``) and surfaced to the + model-settings UI (``mapping.builtin_provider_to_schema``) so the effective + default is visible in the generation-params JSON. + + Thinking defaults on for the Anthropic protocol (where the flag maps to the + Messages API ``thinking`` param), for Qwen models, and for DashScope / + ModelScope; other OpenAI-compatible providers default it off (they stream + ``reasoning_content`` natively or reject the flag).""" + protocol = (protocol or "").lower() + provider = (provider or "").lower() + model = (model or "").lower() + return ( + protocol == "anthropic" + or "qwen" in model + or provider in {"dashscope", "modelscope"} + ) + + +def _apply_model_compatibility(config): + """Normalize provider/model quirks that break the WebUI's shared defaults.""" + import json + + from omegaconf import OmegaConf + + provider = str(OmegaConf.select(config, "llm.service", default="") or "").lower() + model = str(OmegaConf.select(config, "llm.model", default="") or "").lower() + temperature_enabled = False + webui_params = _webui_generation_params(provider, model) + try: + with open(os.path.join(home(), "settings.json"), encoding="utf-8") as fh: + temperature_enabled = bool( + ((json.load(fh).get("llm") or {}).get("temperature_enabled")) + ) + except (OSError, json.JSONDecodeError): + temperature_enabled = False + + # The SDK's base agent.yaml sets temperature=0.3 as a generic default. Many + # OpenAI-compatible models (including deepseek-v4-pro and kimi-k2.5 here) + # reject that value. In the WebUI, temperature should only be sent when the + # user explicitly enables/configures it. + temperature_explicit = temperature_enabled or "temperature" in webui_params + if not temperature_explicit: + generation_config = OmegaConf.select(config, "generation_config", default=None) + if generation_config is not None and "temperature" in generation_config: + del generation_config["temperature"] + + # Some current OpenAI-compatible reasoning/code models reject arbitrary + # temperature values and require temperature=1 when the field is present. + if temperature_explicit and ( + model.startswith("deepseek-v4") + or (provider == "deepseek" and "deepseek-v4" in model) + or model.startswith("kimi-k2") + or (provider == "kimi" and "kimi-k2" in model) + ): + OmegaConf.update(config, "generation_config.temperature", 1.0, merge=True) + + # enable_thinking is a Qwen/DashScope-style extra_body flag. DeepSeek and + # other OpenAI-compatible providers either stream reasoning_content directly + # or do not support the flag; default it off for them so we don't send an + # unsupported extra — UNLESS the user explicitly configured thinking params + # for this provider/model (per-provider thinking control via the WebUI model + # settings: provider default_generation_params / model advanced_params, which + # flow into generation_config via _apply_webui_generation_params). + # On the Anthropic protocol, enable_thinking IS the switch that turns on the + # provider's thinking mode (mapped to the Messages API `thinking` param), so + # keep it — the transport now replays thinking blocks through tool calls. + protocol = str( + OmegaConf.select(config, "llm.protocol", default="") or "").lower() + extra_body = webui_params.get("extra_body") + thinking_user_set = isinstance(extra_body, dict) and ( + "enable_thinking" in extra_body or "thinking_budget" in extra_body + ) + if not thinking_user_set and not thinking_default(protocol, provider, model): + OmegaConf.update( + config, + "generation_config.extra_body.enable_thinking", + False, + merge=True, + ) + return config + + +def _deep_merge(base: dict, override: dict) -> dict: + """Recursively merge ``override`` into ``base`` (nested dicts merged, not + replaced), returning a new dict. Non-dict values (and dict-vs-scalar + mismatches) are overwritten by ``override``.""" + out = dict(base) + for key, value in override.items(): + if isinstance(value, dict) and isinstance(out.get(key), dict): + out[key] = _deep_merge(out[key], value) + else: + out[key] = value + return out + + +def _webui_generation_params(provider: str, model: str) -> dict: + """Provider defaults + model overrides stored in WebUI sidecar metadata. + + Merged deeply so a model-level nested dict (e.g. ``extra_body``) refines the + provider-level one rather than replacing it — otherwise a model that sets a + single ``extra_body`` key would drop the provider's other ``extra_body`` + entries (e.g. ``enable_thinking``).""" + if not provider or not model: + return {} + from app.backends.ms_agent import sidecar + from app.backends.ms_agent.mapping import encode_model_id + + provider_params = ( + sidecar.get("providers", provider) or {} + ).get("default_generation_params") or {} + model_params = ( + sidecar.get("models", encode_model_id(provider, model)) or {} + ).get("advanced_params") or {} + params: dict = {} + if isinstance(provider_params, dict): + params = _deep_merge(params, provider_params) + if isinstance(model_params, dict): + params = _deep_merge(params, model_params) + return params + + +def _apply_webui_generation_params(config): + """Apply generation params configured from the WebUI model settings page.""" + from omegaconf import OmegaConf + + provider = str(OmegaConf.select(config, "llm.service", default="") or "") + model = str(OmegaConf.select(config, "llm.model", default="") or "") + for key, value in _webui_generation_params(provider, model).items(): + OmegaConf.update(config, f"generation_config.{key}", value, merge=True) + return config + + +def session_dir(project, session) -> str: + from ms_agent.project.paths import global_projects_root + + return str(global_projects_root() / project.id / "sessions" / session.id) + + +def session_has_history(project, session) -> bool: + from ms_agent.project import SessionManager + + try: + log = SessionManager(project).get_session_log(session) + return bool(log.get_all_messages()) + except Exception: + return False + + +# UI/meta fields on a managed MCP entry that must not reach the runtime server +# config (mirrors ms_agent.tui.managed_config._MCP_META). +_MCP_META = frozenset({ + "source", "meta", "_scope", "mcp", "implementation", "trust_remote_code", "_removed", +}) + + +def _mcp_reachable(server: dict, timeout: float = 2.0) -> bool: + """Cheap pre-flight so an unreachable MCP can't break the chat turn. + + Remote servers: TCP-connect to host:port. stdio servers: the command must + resolve on PATH (or be an existing file). Reachable-but-broken servers still + pass (rare); the common 'wrong/dead URL or missing command' case is dropped. + """ + import shutil + import socket + from urllib.parse import urlparse + + url = server.get("url") + if url: + try: + u = urlparse(url) + if not u.hostname: + return False + port = u.port or (443 if u.scheme in ("https", "wss") else 80) + with socket.create_connection((u.hostname, port), timeout=timeout): + return True + except Exception: + return False + command = server.get("command") + if command: + return bool(shutil.which(command)) or os.path.isfile(command) + return True # unknown shape — don't drop + + +def _healthy_mcp_config(mcp_config: dict | None) -> dict: + servers = (mcp_config or {}).get("mcpServers") or {} + healthy = {name: s for name, s in servers.items() if _mcp_reachable(s)} + dropped = set(servers) - set(healthy) + if dropped: + logger.warning("skipping unreachable MCP server(s): %s", ", ".join(sorted(dropped))) + return {"mcpServers": healthy} if healthy else {} + + +def build_agent(project, session, *, event_sink, input_source, mcp_config=None, + permission_handler=None): + from ms_agent.agent.llm_agent import LLMAgent + from ms_agent.config import ConfigResolver + from ms_agent.permission.handler import AutoPermissionHandler + from ms_agent.tui.managed_config import ( + merge_skills_into_config, + resolve_mcp_config, + ) + from omegaconf import OmegaConf + + h = home() + resolver = ConfigResolver(global_dir=h, project_root=project.path) + sdir = session_dir(project, session) + session_overrides = { + # ① align the runtime SessionLog with the SessionManager session dir + "session_log": { + "dir": sdir, + "session_key": session.session_key, + }, + # ② project-level personalization instruction + "personalization": {"project_instruction": project.instruction or ""}, + # ③ per-session todo plan: the todo_list tool joins these onto + # output_dir, and an absolute path wins the join — so each session's + # plan lives beside its session log instead of a project-shared + # /plan.json (which made concurrent sessions clobber each + # other's plans). read_plan() resolves the same path. + "tools": { + "todo_list": { + "plan_filename": os.path.join(sdir, "plan.json"), + "plan_md_filename": os.path.join(sdir, "plan.md"), + }, + }, + } + cfg = resolver.resolve( + agent_config=None, + project_path=project.path, + session_overrides=session_overrides, + ) + cfg = _apply_webui_defaults(cfg) + cfg = _apply_webui_memory(cfg, project) + # Restricted-by-default permission; the project sidecar's explicit + # restricted/full-access choice (composer selector) overrides. + from app.backends.ms_agent import sidecar + + meta = sidecar.get("projects", project.id, {}) or {} + cfg = _apply_webui_permission(cfg, meta.get("permission_mode")) + + # Route-A shaping (see tui/app.py::_prepare_config). + shaping = { + "interactive": True, # non-TTY backend: enable interactive lifecycle + # Route chat through the data-driven provider layer (ms_agent/llm/ + # router.py) rather than the legacy hard-coded LLM classes. The new + # layer supports mid-stream interrupt() — abandoning a turn closes the + # upstream streaming response so the server stops generating instead of + # running to completion into a dropped connection. The legacy path is + # being deprecated. + "llm.use_provider_router": True, + "generation_config.stream": True, + "generation_config.stream_output": True, + "generation_config.show_reasoning": True, + "generation_config.extra_body.enable_thinking": True, + "session_log.enabled": True, + "output_dir": project.path, + "max_chat_round": 1000, + } + for key, value in shaping.items(): + OmegaConf.update(cfg, key, value, merge=True) + # Propagate the active provider's wire protocol (openai | anthropic) so the + # provider layer picks the matching transport. A provider may point at + # another vendor's compatible endpoint (e.g. DeepSeek's /anthropic gateway), + # where the endpoint's protocol differs from the service's default transport. + _service = str(OmegaConf.select(cfg, "llm.service", default="") or "") + _protocol = ( + (_read_settings().get("providers") or {}).get(_service) or {} + ).get("protocol") + if _protocol: + OmegaConf.update(cfg, "llm.protocol", _protocol) + cfg = _apply_webui_generation_params(cfg) + cfg = _apply_model_compatibility(cfg) + # Drop any listed InputCallback so restarts never double-register it. + cbs = [c for c in list(getattr(cfg, "callbacks", []) or []) if c != "input_callback"] + OmegaConf.update(cfg, "callbacks", cbs, merge=False) + + # Bridge managed skill sources into the runtime (reused SDK/TUI helper). + cfg = merge_skills_into_config(cfg, h, project.path) + + # MCP: use the SDK's standard mcp_config path (enabled servers only) — far + # more robust to invalid servers than injecting an MCPRuntime (whose remote + # client teardown throws cross-task anyio errors). The registry pre-probes + # servers (connect+initialize) and passes only healthy ones as `mcp_config`; + # fall back to a cheap TCP check for direct callers (tests). Live + # enable/disable is sacrificed — a toggle applies on the next session build. + if mcp_config is None: + mcp_config = _healthy_mcp_config(resolve_mcp_config(h, project.path, None)) + + resume = session_has_history(project, session) + agent = LLMAgent( + cfg, + event_sink=event_sink, + input_source=input_source, + mcp_config=mcp_config or {}, + load_cache=resume, + ) + # The runtime passes a WebPermissionHandler (ask -> SSE authorization card + # -> POST /api/chat/permission resolve, deny on timeout). Direct callers + # (tests/scripts) get auto-allow so restricted mode can't hang them on a + # CLI prompt. + agent.set_permission_handler(permission_handler or AutoPermissionHandler()) + return agent diff --git a/webui/backend/app/backends/ms_agent/instructions.py b/webui/backend/app/backends/ms_agent/instructions.py new file mode 100644 index 000000000..8ee0b6219 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/instructions.py @@ -0,0 +1,57 @@ +"""Instructions adapter — global via PersonalizationSettings.global_instruction, +project via Project.instruction (ProjectManager).""" +from __future__ import annotations + +from datetime import datetime, timezone + +from app.backends.errors import BadRequest +from app.backends.ms_agent.common import home, pm +from app.backends.ms_agent.settings_store import settings_lock +from app.schemas.instruction import Instruction, InstructionUpsert + + +def _ps(): + from ms_agent.personalization import PersonalizationSettings + + return PersonalizationSettings(global_dir=home()) + + +def _parse_scope(scope: str) -> tuple[str, str | None]: + if scope == "global": + return "global", None + if scope.startswith("project:"): + pid = scope.split(":", 1)[1] + if pm().get(pid) is None: + raise BadRequest(f"unknown project: {pid}") + return "project", pid + raise BadRequest(f"invalid scope: {scope!r}") + + +def get_instruction(scope: str) -> Instruction: + kind, pid = _parse_scope(scope) + if kind == "global": + with settings_lock(): + content = _ps().load().global_instruction + else: + content = pm().get(pid).instruction or "" + return Instruction(scope=scope, content=content, updated_at=datetime.now(timezone.utc)) + + +def upsert_instruction(scope: str, body: InstructionUpsert) -> Instruction: + from ms_agent.personalization import PersonalizationConfig + + kind, pid = _parse_scope(scope) + if kind == "global": + with settings_lock(): + ps = _ps() + cur = ps.load() + ps.save( + PersonalizationConfig( + global_instruction=body.content, + memory_enabled=cur.memory_enabled, + memory_backend=cur.memory_backend, + ) + ) + else: + pm().update(pid, instruction=body.content) + return Instruction(scope=scope, content=body.content, updated_at=datetime.now(timezone.utc)) diff --git a/webui/backend/app/backends/ms_agent/mapping.py b/webui/backend/app/backends/ms_agent/mapping.py new file mode 100644 index 000000000..5e660b8de --- /dev/null +++ b/webui/backend/app/backends/ms_agent/mapping.py @@ -0,0 +1,150 @@ +"""Converters between SDK dataclasses and the WebUI pydantic schemas. + +UI-only fields (description, auto-attach, preview, ...) come from the sidecar. +pydantic coerces the SDK's ISO date strings into datetime on assignment. +""" +from __future__ import annotations + +import base64 +from datetime import datetime, timezone + +from app.backends.ms_agent import sidecar +from app.schemas.model import Model as ModelSchema +from app.schemas.project import Project as ProjectSchema +from app.schemas.provider import Provider as ProviderSchema +from app.schemas.session import Session as SessionSchema + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _memory_backend(value) -> str: + return value if value in ("file", "vector") else "file" + + +def _protocol(transport: str) -> str: + return "anthropic" if "anthropic" in (transport or "") else "openai" + + +def _generation_defaults(protocol: str, provider: str) -> dict: + """Effective default generation params the backend applies for a provider, + surfaced read-only so the settings UI can show the thinking knob. Currently + just the protocol-derived ``enable_thinking`` default (config.thinking_default + is the single source of truth).""" + from app.backends.ms_agent.config import thinking_default + + return {"extra_body": {"enable_thinking": thinking_default(protocol, provider)}} + + +def _mask(api_key: str) -> str: + if not api_key: + return "" + if len(api_key) <= 8: + return "****" + return f"{api_key[:4]}****{api_key[-4:]}" + + +def project_to_schema(project) -> ProjectSchema: + from ms_agent.project.types import DEFAULT_PROJECT_ID + + meta = sidecar.get("projects", project.id, {}) or {} + return ProjectSchema( + id=project.id, + name=project.name, + description=meta.get("description", ""), + local_path=project.path, + is_default=(project.id == DEFAULT_PROJECT_ID), + memory_enabled=bool(project.memory_enabled), + memory_backend=_memory_backend(project.memory_backend), + mcp_auto_attach=meta.get("mcp_auto_attach", True), + skill_auto_attach=meta.get("skill_auto_attach", True), + permission_mode=meta.get("permission_mode", "restricted"), + created_at=project.created_at, + ) + + +def session_to_schema(session) -> SessionSchema: + meta = sidecar.get("sessions", session.id, {}) or {} + return SessionSchema( + id=session.id, + title=session.name, + project_id=session.project_id, + updated_at=session.updated_at, + preview=meta.get("preview", ""), + category=meta.get("category", ""), + ) + + +# -- providers / models -------------------------------------------------------- + + +def builtin_provider_to_schema(spec, override: dict | None = None) -> ProviderSchema: + """A registry ProviderSpec, optionally merged with a settings.json custom + entry of the same id (how a user sets creds for a built-in provider).""" + override = override or {} + meta = sidecar.get("providers", spec.name, {}) or {} + # Honor a user's protocol override (e.g. pointing a built-in provider at + # another vendor's Anthropic-compatible endpoint); fall back to the spec's + # default transport. Mirrors base_url so the settings UI and any edit + # round-trip reflect the stored value, not the default. + protocol = (override.get("protocol") + if override.get("protocol") in ("openai", "anthropic") + else _protocol(spec.transport)) + return ProviderSchema( + id=spec.name, + kind="builtin", + name=spec.display_name or spec.name, + base_url=override.get("base_url") or spec.default_base_url, + api_key_masked=_mask(override.get("api_key", "")), + protocol=protocol, + enabled=meta.get("enabled", True), + default_generation_params=meta.get("default_generation_params", {}), + generation_defaults=_generation_defaults(protocol, spec.name), + created_at=_now(), + ) + + +def custom_provider_to_schema(pid: str, entry: dict) -> ProviderSchema: + entry = entry or {} + meta = sidecar.get("providers", pid, {}) or {} + proto = entry.get("protocol") + protocol = proto if proto in ("openai", "anthropic") else "openai" + return ProviderSchema( + id=pid, + kind="custom", + name=entry.get("name", pid), + base_url=entry.get("base_url", ""), + api_key_masked=_mask(entry.get("api_key", "")), + protocol=protocol, + enabled=meta.get("enabled", True), + default_generation_params=meta.get("default_generation_params", {}), + generation_defaults=_generation_defaults(protocol, pid), + created_at=_now(), + ) + + +def encode_model_id(provider_id: str, name: str) -> str: + raw = f"{provider_id}\x1f{name}".encode() + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +def decode_model_id(model_id: str) -> tuple[str, str]: + pad = "=" * (-len(model_id) % 4) + raw = base64.urlsafe_b64decode(model_id + pad).decode() + provider_id, name = raw.split("\x1f", 1) + return provider_id, name + + +def model_to_schema(provider_id: str, name: str) -> ModelSchema: + mid = encode_model_id(provider_id, name) + meta = sidecar.get("models", mid, {}) or {} + return ModelSchema( + id=mid, + provider_id=provider_id, + name=name, + display_name=meta.get("display_name") or name, + is_builtin=False, + advanced_params=meta.get("advanced_params", {}), + created_at=_now(), + ) diff --git a/webui/backend/app/backends/ms_agent/mcp_health.py b/webui/backend/app/backends/ms_agent/mcp_health.py new file mode 100644 index 000000000..b04912a61 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/mcp_health.py @@ -0,0 +1,108 @@ +"""Async MCP health probe. + +A remote MCP whose host is reachable but whose endpoint is stale/invalid (e.g. +"Session terminated") still breaks a chat turn: the agent's connect raises and +aborts the run. A TCP check can't catch that — only a real MCP handshake can. + +This probes each enabled server (connect + initialize) with a timeout, fully +isolated in its own task so a failure/hang/anyio-teardown can't touch the chat, +and returns only the servers that initialized. Runs once per session build. +""" +from __future__ import annotations + +import asyncio +import logging +import os +import shutil + +logger = logging.getLogger("app.ms_agent.mcp_health") + + +async def _remote_handshake(server: dict) -> bool: + """connect + initialize a remote MCP, entered/exited within THIS task so an + anyio cross-task teardown can't leak. Raises on failure.""" + url = server["url"] + transport = str(server.get("transport") or "").lower() + headers = server.get("headers") or None + from mcp import ClientSession + + if transport == "sse": + from mcp.client.sse import sse_client as connect + else: # http / streamable_http + from mcp.client.streamable_http import streamablehttp_client as connect + async with connect(url, headers=headers) as streams: + read, write = streams[0], streams[1] + async with ClientSession(read, write) as session: + await session.initialize() + return True + + +async def _probe_remote(server: dict, timeout: float) -> bool: + if not server.get("url"): + return False + try: + return bool(await asyncio.wait_for(_remote_handshake(server), timeout)) + except Exception: + return False + + +def _short_error(exc: BaseException) -> str: + """Unwrap anyio ExceptionGroups to a short, human-readable reason.""" + while isinstance(exc, BaseExceptionGroup) and exc.exceptions: + exc = exc.exceptions[0] + msg = str(exc).strip() or type(exc).__name__ + return msg[:200] + + +def _runtime_view(server: dict) -> dict: + """The server entry as the runtime will actually use it: ${VAR} + placeholders resolved from the process environment. Management surfaces + keep the placeholder form; probing with it would 401 against a healthy + server. Idempotent on already-expanded entries.""" + from ms_agent.tui.managed_config import expand_env_placeholders + + return expand_env_placeholders(server) + + +async def check_server(server: dict, timeout: float = 6.0) -> tuple[bool, str | None]: + """Probe one server and return (healthy, error_reason). Same handshake as the + build-time filter, but surfaces WHY an enabled server was dropped.""" + server = _runtime_view(server) + if server.get("command"): + ok = _probe_stdio(server) + return ok, (None if ok else "command not found on PATH") + if not server.get("url"): + return False, "server has no url or command" + try: + await asyncio.wait_for(_remote_handshake(server), timeout) + return True, None + except asyncio.TimeoutError: + return False, f"timed out after {int(timeout)}s" + except Exception as exc: # noqa: BLE001 — report the reason, don't raise + return False, _short_error(exc) + + +def _probe_stdio(server: dict) -> bool: + command = server.get("command") + if not command: + return True + return bool(shutil.which(command)) or os.path.isfile(command) + + +async def filter_healthy(servers: dict, timeout: float = 6.0) -> dict: + """Return the subset of servers that pass their probe (concurrently).""" + if not servers: + return {} + + async def _check(name: str, server: dict) -> tuple[str, bool]: + server = _runtime_view(server) + if server.get("command"): + return name, _probe_stdio(server) + return name, await _probe_remote(server, timeout) + + results = await asyncio.gather(*(_check(n, s) for n, s in servers.items())) + healthy = {name: servers[name] for name, ok in results if ok} + dropped = [name for name, ok in results if not ok] + if dropped: + logger.warning("dropping unhealthy MCP server(s): %s", ", ".join(sorted(dropped))) + return healthy diff --git a/webui/backend/app/backends/ms_agent/mcps.py b/webui/backend/app/backends/ms_agent/mcps.py new file mode 100644 index 000000000..9d701ce75 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/mcps.py @@ -0,0 +1,235 @@ +"""MCP adapter — MCPConfigManager (global + per-project) with heuristic mapping +between the WebUI's flat {transport, endpoint} and the SDK's structured server +dict ({command,args} stdio | {url,transport} remote). Ids encode scope+name; +description lives in the sidecar.""" +from __future__ import annotations + +import base64 +import shlex +from datetime import datetime, timezone + +from app.backends.errors import BadRequest, Conflict, NotFound +from app.backends.ms_agent import sidecar +from app.backends.ms_agent.common import home, pm +from app.schemas.mcp import Mcp, McpCreate, McpHealth, McpUpdate + + +def _encode_id(scope: str, name: str) -> str: + return base64.urlsafe_b64encode(f"{scope}\x1f{name}".encode()).decode().rstrip("=") + + +def _decode_id(mcp_id: str) -> tuple[str, str]: + try: + pad = "=" * (-len(mcp_id) % 4) + scope, name = base64.urlsafe_b64decode(mcp_id + pad).decode().split("\x1f", 1) + return scope, name + except Exception: + raise NotFound("mcp not found") + + +def _mm_for(scope: str): + """Return (MCPConfigManager, sdk_scope) for a WebUI scope string.""" + from ms_agent.config import MCPConfigManager + + if scope == "global": + return MCPConfigManager(global_root=home()), "global" + if scope.startswith("project:"): + pid = scope.split(":", 1)[1] + proj = pm().get(pid) + if proj is None: + raise BadRequest(f"unknown project: {pid}") + return MCPConfigManager(global_root=home(), project_root=proj.path), "project" + raise BadRequest(f"invalid scope: {scope!r}") + + +def _endpoint(entry: dict) -> tuple[str, str]: + """(transport, endpoint) from a structured SDK server dict.""" + if entry.get("command"): + parts = [entry["command"], *(entry.get("args") or [])] + return "stdio", shlex.join(str(p) for p in parts) + transport = entry.get("transport") or "sse" + if transport not in ("http", "sse", "streamable_http"): + transport = "sse" + if transport == "streamable_http": + transport = "http" + return transport, entry.get("url", "") + + +def _server(transport: str, endpoint: str, env: dict | None = None, headers: dict | None = None) -> dict: + if transport == "stdio": + parts = shlex.split(endpoint) + if not parts: + raise BadRequest("empty stdio command") + server = {"command": parts[0], "args": parts[1:]} + if env: + server["env"] = env + return server + server = {"url": endpoint, "transport": transport} + if headers: + server["headers"] = headers + return server + + +def _to_schema(scope: str, name: str, entry: dict) -> Mcp: + transport, endpoint = _endpoint(entry) + mid = _encode_id(scope, name) + desc = (sidecar.get("mcps", mid, {}) or {}).get("description") or entry.get("description", "") + created = (entry.get("meta") or {}).get("added_at") or datetime.now(timezone.utc) + return Mcp( + id=mid, + name=name, + description=desc, + transport=transport, + endpoint=endpoint, + enabled=entry.get("enabled", True), + scope=scope, + env=entry.get("env") or {}, + headers=entry.get("headers") or {}, + created_at=created, + ) + + +def list_mcps(scope: str | None = None) -> list[Mcp]: + out: list[Mcp] = [] + if scope: + mm, sdk_scope = _mm_for(scope) + for name, entry in (mm.list(sdk_scope) or {}).items(): + out.append(_to_schema(scope, name, entry)) + else: + from ms_agent.config import MCPConfigManager + + gm = MCPConfigManager(global_root=home()) + for name, entry in (gm.list("global") or {}).items(): + out.append(_to_schema("global", name, entry)) + for proj in pm().list(): + pmm = MCPConfigManager(global_root=home(), project_root=proj.path) + for name, entry in (pmm.list("project") or {}).items(): + out.append(_to_schema(f"project:{proj.id}", name, entry)) + out.sort(key=lambda m: m.created_at) + return out + + +def _enabled_entries() -> list[tuple[str, str, str, dict]]: + """(id, name, scope, server_entry) for every ENABLED MCP across scopes.""" + from ms_agent.config import MCPConfigManager + + out: list[tuple[str, str, str, dict]] = [] + gm = MCPConfigManager(global_root=home()) + for name, entry in (gm.list("global") or {}).items(): + if entry.get("enabled", True): + out.append((_encode_id("global", name), name, "global", entry)) + for proj in pm().list(): + pmm = MCPConfigManager(global_root=home(), project_root=proj.path) + for name, entry in (pmm.list("project") or {}).items(): + if entry.get("enabled", True): + out.append( + (_encode_id(f"project:{proj.id}", name), name, f"project:{proj.id}", entry) + ) + return out + + +def health() -> list[McpHealth]: + """Probe every enabled MCP (connect+initialize) and report status + reason. + On-demand only — never call from list_mcps (each probe is a network handshake + up to the timeout).""" + import asyncio + + from app.backends.ms_agent import mcp_health + + entries = _enabled_entries() + if not entries: + return [] + + async def _run(): + return await asyncio.gather( + *(mcp_health.check_server(entry) for _, _, _, entry in entries) + ) + + results = asyncio.run(_run()) + return [ + McpHealth(id=mid, name=name, scope=scope, healthy=ok, error=err) + for (mid, name, scope, _entry), (ok, err) in zip(entries, results) + ] + + +def health_one(mcp_id: str) -> McpHealth: + """Probe a single MCP server by id and return its health + error reason.""" + import asyncio + + from app.backends.ms_agent import mcp_health + + scope, name = _decode_id(mcp_id) + mm, sdk_scope = _mm_for(scope) + entry = mm.get(name, sdk_scope) + if entry is None: + raise NotFound("mcp not found") + ok, err = asyncio.run(mcp_health.check_server(entry)) + return McpHealth(id=mcp_id, name=name, scope=scope, healthy=ok, error=err) + + +def create_mcp(body: McpCreate) -> Mcp: + mm, sdk_scope = _mm_for(body.scope) + if mm.get(body.name, sdk_scope) is not None: + raise Conflict("mcp name already exists in this scope") + server = _server(body.transport, body.endpoint, body.env, body.headers) + server["enabled"] = body.enabled + mm.add(body.name, server, scope=sdk_scope) + mid = _encode_id(body.scope, body.name) + if body.description: + sidecar.merge("mcps", mid, {"description": body.description}) + entry = mm.get(body.name, sdk_scope) or server + return _to_schema(body.scope, body.name, entry) + + +def get_mcp(mcp_id: str) -> Mcp: + scope, name = _decode_id(mcp_id) + mm, sdk_scope = _mm_for(scope) + entry = mm.get(name, sdk_scope) + if entry is None: + raise NotFound("mcp not found") + return _to_schema(scope, name, entry) + + +def update_mcp(mcp_id: str, body: McpUpdate) -> Mcp: + scope, name = _decode_id(mcp_id) + mm, sdk_scope = _mm_for(scope) + cur = mm.get(name, sdk_scope) + if cur is None: + raise NotFound("mcp not found") + + cur_transport, cur_endpoint = _endpoint(cur) + new_name = body.name or name + if new_name != name and mm.get(new_name, sdk_scope) is not None: + raise Conflict("mcp name already exists in this scope") + transport = body.transport or cur_transport + endpoint = body.endpoint if body.endpoint is not None else cur_endpoint + env = body.env if body.env is not None else cur.get("env") + headers = body.headers if body.headers is not None else cur.get("headers") + server = _server(transport, endpoint, env, headers) + server["enabled"] = body.enabled if body.enabled is not None else cur.get("enabled", True) + + # Clean replace (avoids stale opposite-transport keys); handles rename too. + mm.remove(name, sdk_scope) + mm.add(new_name, server, scope=sdk_scope) + + new_id = _encode_id(scope, new_name) + if body.description is not None: + sidecar.merge("mcps", new_id, {"description": body.description}) + if body.enabled is not None: + from app.backends.ms_agent.runtime import registry + + registry.toggle_mcp(name, body.enabled) # apply to any live session + entry = mm.get(new_name, sdk_scope) or server + return _to_schema(scope, new_name, entry) + + +def delete_mcp(mcp_id: str) -> None: + scope, name = _decode_id(mcp_id) + mm, sdk_scope = _mm_for(scope) + if mm.get(name, sdk_scope) is None: + raise NotFound("mcp not found") + mm.remove(name, sdk_scope) # global: delete; project: mask + sidecar.drop("mcps", mcp_id) + from app.backends.ms_agent.runtime import registry + + registry.toggle_mcp(name, False) # disconnect from any live session diff --git a/webui/backend/app/backends/ms_agent/memory.py b/webui/backend/app/backends/ms_agent/memory.py new file mode 100644 index 000000000..4090b0912 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/memory.py @@ -0,0 +1,261 @@ +"""Per-project memory items over the SDK's unified memory. + +- ``memory_backend="file"``: items are the entry lines of + ``/.ms_agent/memory/MEMORY.md`` — the same store the chat + runtime's FileBasedBackend injects and the agent's ``memory`` tool edits. + Ids are content hashes (the file has no per-entry ids); ``updated_at`` is + the file mtime. +- ``memory_backend="vector"``: items are mem0 memories (user_id = project id, + embedded local qdrant under the project memory dir). UI writes use + ``infer=False`` so a note is stored verbatim; the agent's conversational + ingestion (fact extraction) shares the same store. The live chat runtime's + mem0 instance is reused when present — embedded qdrant is single-client. + +Same guards as the mock: real project, not default, memory enabled. +""" +from __future__ import annotations + +import hashlib +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + +from app.backends.errors import BadRequest, NotFound +from app.schemas.memory import MemoryItem, MemoryItemCreate, MemoryItemUpdate + + +def _guard(pid: str): + from ms_agent.project.types import DEFAULT_PROJECT_ID + + from app.backends.ms_agent.common import pm + + proj = pm().get(pid) + if proj is None: + raise NotFound("project not found") + if proj.id == DEFAULT_PROJECT_ID: + raise BadRequest("default project does not support memory") + if not proj.memory_enabled: + raise BadRequest("memory is disabled for this project") + return proj + + +def _storage(proj): + from ms_agent.memory.unified.config import MemoryConfig + from ms_agent.memory.unified.storage.file_storage import FileMemoryStorage + from ms_agent.project.paths import memory_dir + + cfg = MemoryConfig(base_dir=str(memory_dir(proj.path))) + return FileMemoryStorage(cfg) + + +def _invalidate_live(proj) -> None: + """Drop the snapshot/content cache of any live agent sharing this store, so + a UI edit is visible to the next turn without a runtime rebuild.""" + from ms_agent.memory.memory_manager import SharedMemoryManager + from ms_agent.project.paths import memory_dir + + target = Path(str(memory_dir(proj.path))) + for mem in list(SharedMemoryManager._instances.values()): + base = getattr(getattr(mem, "mem_config", None), "base_dir", None) + if base and Path(str(base)) == target and hasattr(mem, "invalidate_snapshot"): + mem.invalidate_snapshot() + + +def _is_vector(proj) -> bool: + return (getattr(proj, "memory_backend", None) or "file") == "vector" + + +def _mem0_result_list(res) -> list[dict]: + if isinstance(res, dict): + res = res.get("results", []) + return list(res or []) + + +def _mem0_get_all(m0, pid: str) -> list[dict]: + """mem0 2.x: filters= + top_k (default 20 is too small for a notes list); + 1.x: user_id kwarg.""" + try: + res = m0.get_all(filters={"user_id": pid}, top_k=200) + except TypeError: + res = m0.get_all(user_id=pid) + return _mem0_result_list(res) + + +def _mem0_update(m0, item_id: str, content: str) -> None: + try: + m0.update(memory_id=item_id, text=content) + except TypeError: + m0.update(memory_id=item_id, data=content) + + +@contextmanager +def _mem0_for(proj): + """Yield a mem0.Memory over the project's store. + + Prefer the live chat runtime's instance (same process — embedded qdrant + holds a file lock, so a second client on the same path would fail). Build + a transient instance otherwise and close its vector client afterwards.""" + from ms_agent.memory.memory_manager import SharedMemoryManager + from ms_agent.project.paths import memory_dir + + target = Path(str(memory_dir(proj.path))) + for mem in list(SharedMemoryManager._instances.values()): + base = getattr(getattr(mem, "mem_config", None), "base_dir", None) + backend = getattr(mem, "_backend", None) + live = getattr(backend, "_mem0", None) + if base and Path(str(base)) == target and live is not None: + yield live + return + + from app.backends.ms_agent.config import _mem0_options + + try: + import mem0 + except Exception as exc: # pragma: no cover - import guard + raise BadRequest(f"vector memory unavailable: {exc}") + options = _mem0_options(proj) + if options is None: + raise BadRequest("vector memory unavailable: no embeddings provider configured") + try: + m0 = mem0.Memory.from_config(options) + except Exception as exc: + raise BadRequest(f"vector memory init failed: {exc}") + try: + yield m0 + finally: + try: # release the embedded qdrant lock promptly + m0.vector_store.client.close() + except Exception: + pass + + +def _vector_item(pid: str, r: dict) -> MemoryItem: + at = r.get("updated_at") or r.get("created_at") or _now() + return MemoryItem( + id=str(r.get("id") or ""), + project_id=pid, + content=str(r.get("memory") or r.get("text") or ""), + updated_at=str(at), + ) + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _entry_id(line: str) -> str: + return "mem_" + hashlib.sha1(line.encode("utf-8")).hexdigest()[:12] + + +def _entries(storage) -> list[str]: + return [l.strip() for l in storage.get_content().splitlines() if l.strip()] + + +def _mtime(storage) -> str: + try: + ts = storage.memory_path.stat().st_mtime + except OSError: + return datetime.now(timezone.utc).isoformat() + return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() + + +def _item(pid: str, line: str, updated_at: str) -> MemoryItem: + return MemoryItem( + id=_entry_id(line), project_id=pid, content=line, updated_at=updated_at + ) + + +def _migrate_sidecar(pid: str, proj, storage) -> None: + """One-time: fold legacy sidecar note items into MEMORY.md (pre-unified + versions kept the UI list in webui_meta.json, invisible to the agent).""" + from app.backends.ms_agent import sidecar + + legacy = list(sidecar.get("memory", pid, []) or []) + if not legacy: + return + for item in legacy: + content = str(item.get("content") or "").strip() + if content: + storage._add_entry(content) + sidecar.drop("memory", pid) + _invalidate_live(proj) + + +def list_items(pid: str) -> list[MemoryItem]: + proj = _guard(pid) + if _is_vector(proj): + with _mem0_for(proj) as m0: + rows = _mem0_get_all(m0, pid) + items = [_vector_item(pid, r) for r in rows] + return [i for i in items if i.content] + storage = _storage(proj) + _migrate_sidecar(pid, proj, storage) + at = _mtime(storage) + # File order == MEMORY.md order (what the agent reads). + return [_item(pid, line, at) for line in _entries(storage)] + + +def create_item(pid: str, body: MemoryItemCreate) -> MemoryItem: + proj = _guard(pid) + content = (body.content or "").strip() + if not content: + raise BadRequest("memory content is empty") + if _is_vector(proj): + with _mem0_for(proj) as m0: + # infer=False stores the note verbatim (no LLM rewriting). + res = _mem0_result_list(m0.add(content, user_id=pid, infer=False)) + added = next((r for r in res if r.get("id")), None) + if added is None: + raise BadRequest("vector memory rejected the entry") + _invalidate_live(proj) + return _vector_item(pid, {**added, "memory": added.get("memory") or content}) + storage = _storage(proj) + if not storage._add_entry(content): + raise BadRequest("memory is full (char budget) — remove entries first") + _invalidate_live(proj) + return _item(pid, content, _mtime(storage)) + + +def update_item(pid: str, item_id: str, body: MemoryItemUpdate) -> MemoryItem: + proj = _guard(pid) + content = (body.content or "").strip() + if not content: + raise BadRequest("memory content is empty") + if _is_vector(proj): + with _mem0_for(proj) as m0: + try: + _mem0_update(m0, item_id, content) + except Exception as exc: + if "not found" in str(exc).lower(): + raise NotFound("memory item not found") + raise BadRequest(f"vector memory update failed: {exc}") + _invalidate_live(proj) + return _vector_item(pid, {"id": item_id, "memory": content, "updated_at": _now()}) + storage = _storage(proj) + old = next((l for l in _entries(storage) if _entry_id(l) == item_id), None) + if old is None: + raise NotFound("memory item not found") + if content != old and not storage.replace_entry(old, content): + raise BadRequest("memory update rejected (char budget or security scan)") + _invalidate_live(proj) + return _item(pid, content, _mtime(storage)) + + +def delete_item(pid: str, item_id: str) -> None: + proj = _guard(pid) + if _is_vector(proj): + with _mem0_for(proj) as m0: + try: + m0.delete(memory_id=item_id) + except Exception as exc: + if "not found" in str(exc).lower() or isinstance(exc, IndexError): + raise NotFound("memory item not found") + raise BadRequest(f"vector memory delete failed: {exc}") + _invalidate_live(proj) + return + storage = _storage(proj) + old = next((l for l in _entries(storage) if _entry_id(l) == item_id), None) + if old is None: + raise NotFound("memory item not found") + storage.remove_entry(old) + _invalidate_live(proj) diff --git a/webui/backend/app/backends/ms_agent/model_link.py b/webui/backend/app/backends/ms_agent/model_link.py new file mode 100644 index 000000000..7849d625d --- /dev/null +++ b/webui/backend/app/backends/ms_agent/model_link.py @@ -0,0 +1,130 @@ +"""Keep the model link coherent. + +Three things must agree for the UI to work: + * chat's active credentials -> settings.json `llm` block (what ConfigResolver reads) + * the default model -> settings.json `default_model` = "provider/model" + * the model catalog -> settings.json `providers[p].models` (what /api/models lists) + +Selecting a model in the UI sends a base64 Model.id (provider+name); this module +decodes it, points the llm block + default_model at it, and makes sure it's in +the catalog. Credential precedence: provider override -> current llm block (same +provider) -> built-in registry default. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +from app.backends.ms_agent.common import home +from app.backends.ms_agent.settings_store import settings_lock + + +def _path() -> Path: + return Path(home()) / "settings.json" + + +def _load_unlocked() -> dict: + p = _path() + if not p.exists(): + return {} + try: + return json.loads(p.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {} + + +def _load() -> dict: + with settings_lock(): + return _load_unlocked() + + +def _save_unlocked(data: dict) -> None: + p = _path() + p.parent.mkdir(parents=True, exist_ok=True) + tmp = p.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, p) + + +def _save(data: dict) -> None: + with settings_lock(): + _save_unlocked(data) + + +def _registry_base_url(provider: str) -> str: + try: + from ms_agent.llm.spec import get_registry + + for spec in get_registry().list_providers(): + if spec.name == provider: + return spec.default_base_url or "" + except Exception: + pass + return "" + + +def active_model(data: dict | None = None) -> tuple[str | None, str | None]: + """(provider, model) of the currently-active model, best-effort.""" + data = data if data is not None else _load() + dm = data.get("default_model") + if dm and "/" in dm: + provider, model = dm.split("/", 1) + return provider, model + llm = data.get("llm", {}) or {} + if dm: # bare model name — infer its provider from the catalog or the llm block + for p, v in (data.get("providers", {}) or {}).items(): + if dm in ((v or {}).get("models") or []): + return p, dm + return llm.get("provider"), dm + if llm.get("provider") and llm.get("model"): + return llm.get("provider"), llm.get("model") + return None, None + + +def set_active_model(provider: str, model: str) -> None: + """Point the llm block + default_model at (provider, model) and ensure the + model is in the provider's catalog. Preserves working credentials.""" + with settings_lock(): + data = _load_unlocked() + llm = data.get("llm", {}) or {} + prov_entry = (data.get("providers", {}) or {}).get(provider, {}) or {} + same_provider = llm.get("provider") == provider + + if "api_key" in prov_entry: + api_key = prov_entry.get("api_key") or "" + else: + api_key = (llm.get("api_key") if same_provider else "") or "" + if "base_url" in prov_entry: + base_url = prov_entry.get("base_url") or _registry_base_url(provider) + else: + base_url = (llm.get("base_url") if same_provider else "") or _registry_base_url(provider) + + new_llm = {"provider": provider, "model": model} + if api_key: + new_llm["api_key"] = api_key + if base_url: + new_llm["base_url"] = base_url + data["llm"] = new_llm + data["default_model"] = f"{provider}/{model}" + + prov = data.setdefault("providers", {}).setdefault(provider, {}) + prov.setdefault("protocol", "openai") + if api_key and not prov.get("api_key"): + prov["api_key"] = api_key + if base_url and not prov.get("base_url"): + prov["base_url"] = base_url + models = prov.setdefault("models", []) + if model and model not in models: + models.append(model) + + _save_unlocked(data) + + +def ensure_link() -> None: + """Normalize on boot: default_model -> 'provider/model' and the active model + registered in the catalog, so the chat dropdown is never empty for a + configured model.""" + provider, model = active_model() + if provider and model: + set_active_model(provider, model) diff --git a/webui/backend/app/backends/ms_agent/models.py b/webui/backend/app/backends/ms_agent/models.py new file mode 100644 index 000000000..d93936fb2 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/models.py @@ -0,0 +1,92 @@ +"""Models adapter — models live as string lists inside settings.json providers. + +A synthetic id encodes (provider_id, name); display_name / advanced_params (not +modelled by the SDK) live in the sidecar keyed by that id.""" +from __future__ import annotations + +from app.backends.errors import BadRequest, NotFound +from app.backends.ms_agent import sidecar +from app.backends.ms_agent.common import home +from app.backends.ms_agent.mapping import ( + decode_model_id, + encode_model_id, + model_to_schema, +) +from app.backends.ms_agent.settings_store import settings_lock +from app.schemas.model import Model, ModelCreate, ModelUpdate + + +def _msm(): + from ms_agent.config.model_settings import ModelSettingsManager + + return ModelSettingsManager(global_dir=home()) + + +def _builtin_ids() -> set[str]: + from ms_agent.llm.spec import get_registry + + return {s.name for s in get_registry().list_providers()} + + +def _model_names(provider_id: str) -> list[str]: + with settings_lock(): + return _msm().list_custom_providers().get(provider_id, {}).get("models", []) + + +def list_models(provider_id: str | None = None) -> list[Model]: + with settings_lock(): + custom = _msm().list_custom_providers() + out: list[Model] = [] + for pid, entry in custom.items(): + if provider_id and pid != provider_id: + continue + for name in entry.get("models", []): + out.append(model_to_schema(pid, name)) + return out + + +def create_model(body: ModelCreate) -> Model: + with settings_lock(): + msm = _msm() + if body.provider_id not in msm.list_custom_providers() and body.provider_id not in _builtin_ids(): + raise BadRequest("unknown provider") + msm.add_model(body.provider_id, body.name) + mid = encode_model_id(body.provider_id, body.name) + side = {} + if body.display_name: + side["display_name"] = body.display_name + if body.advanced_params: + side["advanced_params"] = body.advanced_params + if side: + sidecar.merge("models", mid, side) + return model_to_schema(body.provider_id, body.name) + + +def _decode(model_id: str) -> tuple[str, str]: + try: + return decode_model_id(model_id) + except Exception: + raise NotFound("model not found") + + +def update_model(model_id: str, body: ModelUpdate) -> Model: + provider_id, name = _decode(model_id) + if name not in _model_names(provider_id): + raise NotFound("model not found") + side = {} + if body.display_name is not None: + side["display_name"] = body.display_name + if body.advanced_params is not None: + side["advanced_params"] = body.advanced_params + if side: + sidecar.merge("models", model_id, side) + return model_to_schema(provider_id, name) + + +def delete_model(model_id: str) -> None: + provider_id, name = _decode(model_id) + with settings_lock(): + if name not in _msm().list_custom_providers().get(provider_id, {}).get("models", []): + raise NotFound("model not found") + _msm().remove_model(provider_id, name) + sidecar.drop("models", model_id) diff --git a/webui/backend/app/backends/ms_agent/profile.py b/webui/backend/app/backends/ms_agent/profile.py new file mode 100644 index 000000000..4706ebbaf --- /dev/null +++ b/webui/backend/app/backends/ms_agent/profile.py @@ -0,0 +1,31 @@ +"""Profile adapter — description via ProfileManager (profile.md), the structured +agent_calls_user field via sidecar.""" +from __future__ import annotations + +from datetime import datetime, timezone + +from app.backends.ms_agent import sidecar +from app.backends.ms_agent.common import home +from app.schemas.profile import Profile, ProfileUpsert + + +def _pm(): + from ms_agent.personalization import ProfileManager + + return ProfileManager(global_dir=home()) + + +def get_profile() -> Profile: + return Profile( + agent_calls_user=sidecar.get("profile", "agent_calls_user", "User"), + description=_pm().read(), + updated_at=datetime.now(timezone.utc), + ) + + +def update_profile(body: ProfileUpsert) -> Profile: + if body.description is not None: + _pm().write(body.description) + if body.agent_calls_user is not None: + sidecar.put("profile", "agent_calls_user", body.agent_calls_user) + return get_profile() diff --git a/webui/backend/app/backends/ms_agent/projects.py b/webui/backend/app/backends/ms_agent/projects.py new file mode 100644 index 000000000..463d48b3a --- /dev/null +++ b/webui/backend/app/backends/ms_agent/projects.py @@ -0,0 +1,117 @@ +"""Projects adapter — ProjectManager + sidecar (description / auto-attach).""" +from __future__ import annotations + +from app.backends.errors import BadRequest, NotFound +from app.backends.ms_agent import sidecar +from app.backends.ms_agent.common import home, pm +from app.backends.ms_agent.mapping import project_to_schema +from app.schemas.project import Project, ProjectCreate, ProjectUpdate + + +def _memory_defaults() -> tuple[bool, str]: + """New-project memory defaults come from the global personalization block + (what the agent-settings page writes).""" + from ms_agent.personalization import PersonalizationSettings + + cfg = PersonalizationSettings(global_dir=home()).load() + return bool(cfg.memory_enabled), (cfg.memory_backend or "file") + + +def _is_default(pid: str) -> bool: + from ms_agent.project.types import DEFAULT_PROJECT_ID + + return pid == DEFAULT_PROJECT_ID + + +def list_projects() -> list[Project]: + from ms_agent.project.types import DEFAULT_PROJECT_ID + + projects = pm().list() + projects.sort(key=lambda p: (p.id != DEFAULT_PROJECT_ID, p.created_at)) + return [project_to_schema(p) for p in projects] + + +def create_project(body: ProjectCreate) -> Project: + manager = pm() + default_enabled, default_backend = _memory_defaults() + mem_enabled = body.memory_enabled if body.memory_enabled is not None else default_enabled + mem_backend = body.memory_backend if body.memory_backend is not None else default_backend + + if body.local_path: + # "use an existing folder": path is identity, dedups on reopen. + proj = manager.open_folder( + path=body.local_path, + name=body.name, + memory_enabled=mem_enabled, + memory_backend=mem_backend, + ) + else: + proj = manager.create( + name=body.name, + memory_enabled=mem_enabled, + memory_backend=mem_backend, + # The runtime writes products directly under the project dir, so the + # extra `workspace/` subdir is unused clutter — don't create it. + init_workspace=False, + ) + if body.description: + sidecar.merge("projects", proj.id, {"description": body.description}) + return project_to_schema(proj) + + +def get_project(pid: str) -> Project: + proj = pm().get(pid) + if proj is None: + raise NotFound("project not found") + return project_to_schema(proj) + + +def update_project(pid: str, body: ProjectUpdate) -> Project: + manager = pm() + proj = manager.get(pid) + if proj is None: + raise NotFound("project not found") + + fields: dict = {} + if body.name is not None: + fields["name"] = body.name + if body.local_path is not None: + fields["path"] = body.local_path + if body.memory_enabled is not None: + if _is_default(pid) and body.memory_enabled: + raise BadRequest("default project cannot enable memory") + fields["memory_enabled"] = body.memory_enabled + if fields: + proj = manager.update(pid, **fields) + + side = { + k: getattr(body, k) + for k in ( + "description", + "mcp_auto_attach", + "skill_auto_attach", + "permission_mode", + ) + if getattr(body, k) is not None + } + if side: + sidecar.merge("projects", pid, side) + if body.permission_mode is not None: + # Hot-apply to every live runtime of this project so the very next + # tool call obeys the new mode — no agent rebuild, no turn restart. + from app.backends.ms_agent.runtime import registry + + registry.set_project_permission_mode(pid, body.permission_mode) + return project_to_schema(proj) + + +def delete_project(pid: str) -> None: + manager = pm() + proj = manager.get(pid) + if proj is None: + raise NotFound("project not found") + if _is_default(pid): + raise BadRequest("cannot delete default project") + manager.delete(pid) # removes the project dir incl. its sessions + sidecar.drop("projects", pid) + sidecar.drop("memory", pid) diff --git a/webui/backend/app/backends/ms_agent/providers.py b/webui/backend/app/backends/ms_agent/providers.py new file mode 100644 index 000000000..6fab99f4e --- /dev/null +++ b/webui/backend/app/backends/ms_agent/providers.py @@ -0,0 +1,158 @@ +"""Providers adapter — ModelSettingsManager + registry + sidecar. + +Built-ins come from the read-only registry; customs from settings.json +`providers`. A custom entry whose id equals a built-in id is a credential +override and is merged into that built-in row (kept as a single entry).""" +from __future__ import annotations + +from app.backends.errors import BadRequest, Conflict, NotFound +from app.backends.ms_agent import model_link, sidecar +from app.backends.ms_agent.common import home +from app.backends.ms_agent.mapping import ( + builtin_provider_to_schema, + custom_provider_to_schema, + encode_model_id, +) +from app.backends.ms_agent.settings_store import settings_lock +from app.schemas.provider import Provider, ProviderCreate, ProviderUpdate + + +def _protocol(transport: str) -> str: + return "anthropic" if "anthropic" in (transport or "") else "openai" + + +def _msm(): + from ms_agent.config.model_settings import ModelSettingsManager + + return ModelSettingsManager(global_dir=home()) + + +def _specs(): + from ms_agent.llm.spec import get_registry + + return get_registry().list_providers() + + +def _builtin_ids() -> set[str]: + return {s.name for s in _specs()} + + +def list_providers() -> list[Provider]: + with settings_lock(): + custom = _msm().list_custom_providers() + builtin_ids = _builtin_ids() + out = [builtin_provider_to_schema(s, custom.get(s.name)) for s in _specs()] + out += [ + custom_provider_to_schema(pid, entry) for pid, entry in custom.items() + if pid not in builtin_ids + ] + return out + + +def get_provider(pid: str) -> Provider: + with settings_lock(): + custom = _msm().list_custom_providers() + if pid in _builtin_ids(): + spec = next(s for s in _specs() if s.name == pid) + return builtin_provider_to_schema(spec, custom.get(pid)) + if pid in custom: + return custom_provider_to_schema(pid, custom[pid]) + raise NotFound("provider not found") + + +def create_provider(body: ProviderCreate) -> Provider: + with settings_lock(): + msm = _msm() + if body.id in _builtin_ids() or body.id in msm.list_custom_providers(): + raise Conflict("provider id already exists") + msm.add_provider( + body.id, + name=body.name, + protocol=body.protocol, + base_url=body.base_url or None, + models=[], + ) + custom = msm.list_custom_providers().get(body.id, {}) + if body.default_generation_params: + sidecar.merge( + "providers", + body.id, + {"default_generation_params": body.default_generation_params}, + ) + return custom_provider_to_schema(body.id, custom) + + +def update_provider(pid: str, body: ProviderUpdate) -> Provider: + with settings_lock(): + msm = _msm() + custom = msm.list_custom_providers() + if pid not in _builtin_ids() and pid not in custom: + raise NotFound("provider not found") + + cur = custom.get(pid, {}) + settings_changed = any(v is not None + for v in (body.name, body.protocol, + body.base_url, body.api_key)) + if settings_changed: + msm.add_provider( + pid, + name=body.name if body.name is not None else cur.get("name"), + protocol=(body.protocol if body.protocol is not None else + cur.get("protocol")) or "openai", + api_key=body.api_key + if body.api_key is not None else cur.get("api_key"), + base_url=body.base_url + if body.base_url is not None else cur.get("base_url"), + models=cur.get("models", []), + ) + active_provider, active_model = model_link.active_model() + if active_provider == pid and active_model: + model_link.set_active_model(pid, active_model) + side = {} + if body.enabled is not None: + side["enabled"] = body.enabled + if body.default_generation_params is not None: + side["default_generation_params"] = body.default_generation_params + if side: + sidecar.merge("providers", pid, side) + return get_provider(pid) + + +def delete_provider(pid: str) -> None: + with settings_lock(): + msm = _msm() + custom = msm.list_custom_providers() + if pid not in custom: + if pid in _builtin_ids(): + raise BadRequest("cannot delete builtin provider") + raise NotFound("provider not found") + model_names = list(custom.get(pid, {}).get("models", [])) + msm.remove_provider(pid) + for name in model_names: + sidecar.drop("models", encode_model_id(pid, name)) + sidecar.drop("providers", pid) + + +def get_provider_secret(pid: str) -> tuple[str, str, str]: + """Return (base_url, protocol, plaintext api_key) for model discovery. + + custom: read the plaintext api_key from settings.json; builtin: spec + default base_url + protocol, with any credential override's api_key. + Raises NotFound if the provider does not exist. + """ + with settings_lock(): + custom = _msm().list_custom_providers() + if pid in _builtin_ids(): + spec = next(s for s in _specs() if s.name == pid) + override = custom.get(pid, {}) or {} + base_url = override.get("base_url") or spec.default_base_url or "" + protocol = _protocol(spec.transport) + return base_url, protocol, override.get("api_key", "") or "" + if pid in custom: + entry = custom[pid] or {} + protocol = entry.get("protocol") + protocol = protocol if protocol in ("openai", + "anthropic") else "openai" + return entry.get("base_url", "") or "", protocol, entry.get( + "api_key", "") or "" + raise NotFound("provider not found") diff --git a/webui/backend/app/backends/ms_agent/runtime.py b/webui/backend/app/backends/ms_agent/runtime.py new file mode 100644 index 000000000..0dc4d8d43 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/runtime.py @@ -0,0 +1,463 @@ +"""Per-session live-agent registry (Route A). + +Each SDK session owns one long-lived ``LLMAgent`` whose ``run(None)`` loop runs +in a background task, pulling prompts from an input queue and emitting structured +events to a sink. A POST enqueues one user turn and drains the sink until the +turn completes. One turn per session at a time (``turn_lock``). +""" +from __future__ import annotations + +import asyncio +import logging +import time + +from app.backends.ms_agent.config import build_agent + +logger = logging.getLogger("app.ms_agent.runtime") + +# Turn lifecycle (product decision, aligned with the frontend team): a running +# turn is NEVER stopped by a client going away — navigation, refresh AND a +# fully closed browser all leave it running to completion in the background +# (the SessionLog persists the answer; the viewer re-attaches or reloads it +# later). The ONLY thing that cancels a turn is the explicit Stop button +# (POST /api/chat/interrupt). POST /api/presence remains as a running-state +# poll that drives the sidebar spinners / re-attach, not a liveness contract. + +# Internal sentinels pushed onto the turn queue (not AgentEvents): +# TURN_END — the agent called read_prompt, i.e. the current turn's answer +# is fully streamed and it is waiting for the next input. This +# is the reliable Route-A turn delimiter (turn_completed fires +# per-round and is mistimed — it emits only after the *next* +# input arrives, see llm_agent.py:1591 vs :1617). +# DRIVER_* — the agent loop stopped (error / EOF / cancellation). +TURN_END = "__turn_end__" +DRIVER_ERROR = "__driver_error__" +DRIVER_DONE = "__driver_done__" + + +class WebInputSource: + """InputSource whose read_prompt blocks on a queue fed by POST /api/chat. + + The first read is the initial prompt (session start / resume next-turn) and + marks no boundary; every later read means the previous turn finished, so it + pushes a TURN_END sentinel to the active turn queue before blocking. + + A queue item is either the prompt string, or ``(prompt, marker)`` where + ``marker`` is a display-only skill-invocation record. The marker is written + here — immediately before the SDK appends the (expanded) user row — so its + seq precedes that row's, letting history replay show the user's original + text instead of the expanded skill prompt. ``log_getter`` is lazy because + the session log is created when the agent's run loop starts.""" + + def __init__(self, queue: "asyncio.Queue", sink: "QueueEventSink", + log_getter=None) -> None: + self._queue = queue + self._sink = sink + self._log_getter = log_getter + self._first = True + + async def read_prompt(self, prompt: str = ">>> ") -> str: + if self._first: + self._first = False + else: + self._sink.push({"type": TURN_END}) + item = await self._queue.get() + text, marker = item if isinstance(item, tuple) else (item, None) + if marker and self._log_getter is not None: + try: + log = self._log_getter() + if log is not None and hasattr(log, "record_skill_invocation"): + log.record_skill_invocation(marker) + except Exception: # display-only; never block the turn + logger.debug("skill invocation marker skipped", exc_info=True) + return text + + +class QueueEventSink: + """AgentEventSink as a broadcast log of the CURRENT turn's events. + + push() appends to an in-memory list (cheap — it is on the token hot path) + and pulses an Event; any number of consumers read cursor-style via + next_event(). This is what makes late re-attach possible: a viewer who + navigates back to a running session replays the buffer from index 0 (full + catch-up of the in-flight turn) and then follows the live tail, while the + original consumer/drain keeps its own cursor. new_turn() resets the buffer + at each turn start (the previous turn's consumers have all seen their + terminal marker by then — the turn_lock guarantees turns don't overlap). + + Events are stamped with a monotonic ``_ts`` so replayed reasoning keeps its + 真实 elapsed time instead of the replay instant. + """ + + def __init__(self) -> None: + self._events: list[dict] = [] + self._pulse = asyncio.Event() # single persistent event: set on append/swap + + def new_turn(self) -> None: + # Fresh list (not clear()) so a straggler consumer from the previous + # turn detects the swap (its captured list stops being current) instead + # of replaying the new turn; the pulse wakes such stragglers. + self._events = [] + self._pulse.set() + + def push(self, payload: dict) -> None: + self._events.append({**payload, "_ts": time.monotonic()}) + self._pulse.set() + + async def next_event(self, pos: int) -> tuple[dict, int]: + """The event at cursor ``pos`` (waiting for it if not produced yet). + + Returns a synthesized TURN_END when the buffer was swapped by + new_turn() — the consumer belongs to a finished turn and must wind + down. Multi-consumer safe: every waiter re-checks after each pulse.""" + events = self._events + while pos >= len(events): + if events is not self._events: + return {"type": TURN_END}, pos + self._pulse.clear() + if pos < len(events) or events is not self._events: + continue + await self._pulse.wait() + return events[pos], pos + 1 + + @property + def size(self) -> int: + return len(self._events) + + def emit(self, event) -> None: # ms_agent.ui.events.AgentEventSink + try: + self.push(event.to_dict()) + except Exception: # never let a renderer error break the agent loop + logger.debug("event emit dropped", exc_info=True) + + +class _PermissionEmitter: + """WebPermissionHandler EventEmitter: forwards its raw `permission_request` + dict straight onto the turn queue (it is not an AgentEvent, so it must not + go through QueueEventSink.emit's to_dict()).""" + + def __init__(self, sink: "QueueEventSink") -> None: + self._sink = sink + + def emit(self, event: dict) -> None: + self._sink.push(event) + + +def _persisting_permission_handler(sink, session_log_getter): + """WebPermissionHandler that also persists each resolved authorization to the + session log (``record_permission``), so history can replay the card in its + approved/rejected state. The record is display-only (filtered out of the LLM + context by SessionLog). ``session_log_getter`` is called lazily at ask() time + because the log is created when the agent's run loop starts (after this + handler is built).""" + from ms_agent.permission.handler import ( + PermissionAction, + WebPermissionHandler, + ) + + class _PersistingWebPermissionHandler(WebPermissionHandler): + async def ask(self, tool_name, tool_args, context, suggestions=None, + call_id=""): + response = await super().ask( + tool_name, tool_args, context, suggestions, call_id=call_id) + try: + log = session_log_getter() + if log is not None and hasattr(log, "record_permission"): + state = ( + "rejected" + if response.action == PermissionAction.DENY + else "approved" + ) + # Persist the gating tool_call's id so history replay pairs + # this decision to the exact call — robust when a round + # fires several identical tool calls in parallel (args alone + # can't disambiguate). Empty when the adapter hadn't assigned + # an id yet; reconstruct then falls back to arg matching. + log.record_permission({ + "tool_name": tool_name, + "arguments": tool_args, + "state": state, + "call_id": str(call_id or ""), + }) + except Exception: # never let persistence break the turn + logger.debug("permission record skipped", exc_info=True) + return response + + return _PersistingWebPermissionHandler(_PermissionEmitter(sink)) + + +class SessionRuntime: + def __init__(self, project, session, mcp_config: dict | None = None) -> None: + from app.backends.ms_agent import model_link + + self.project = project + self.session = session + # (provider, model) baked into this agent — the resolver reads it from + # settings.json.llm, so a later model switch is detected by comparing + # against active_model() and triggers a rebuild (see RuntimeRegistry.get). + self.model_key = model_link.active_model() + self.input_queue: "asyncio.Queue[str]" = asyncio.Queue() + self.sink = QueueEventSink() + self.input_source = WebInputSource( + self.input_queue, self.sink, + log_getter=lambda: getattr(self.agent, "session_log", None), + ) + self.turn_lock = asyncio.Lock() + # Count of live SSE viewers (the original /api/chat stream plus any + # /api/chat/attach viewers). Diagnostic state: a background-continued + # turn has zero watchers. Nothing cancels a turn based on this — only + # the explicit Stop button ends a turn early. + self.watchers = 0 + # Restricted-mode asks suspend on this handler until the frontend + # answers via POST /api/chat/permission (deny after its timeout); the + # decision is persisted for replay. The getter reads the log lazily — + # it is created when the agent's run loop starts, after this line. + self.permission_handler = _persisting_permission_handler( + self.sink, lambda: getattr(self.agent, "session_log", None) + ) + self.agent = build_agent( + project, + session, + event_sink=self.sink, + input_source=self.input_source, + mcp_config=mcp_config, + permission_handler=self.permission_handler, + ) + self.run_task: asyncio.Task = asyncio.create_task(self._drive()) + + async def _drive(self) -> None: + try: + gen = await self.agent.run(None, stream=True) + async for _ in gen: + pass + except (EOFError, asyncio.CancelledError): + self.sink.push({"type": DRIVER_DONE}) + except Exception as exc: # noqa: BLE001 — surface, don't crash the server + logger.warning("agent loop error", exc_info=True) + self.sink.push({"type": DRIVER_ERROR, "message": f"{type(exc).__name__}: {exc}"}) + else: + self.sink.push({"type": DRIVER_DONE}) + + async def enqueue(self, text: str, marker: dict | None = None) -> None: + # Plain string when no marker, so simple consumers/tests stay unchanged. + await self.input_queue.put((text, marker) if marker else text) + + async def aclose(self) -> None: + if not self.run_task.done(): + self.run_task.cancel() + try: + await self.run_task + except (asyncio.CancelledError, Exception): + pass + try: + await self.agent.cleanup_tools() + except Exception: + logger.debug("agent cleanup skipped", exc_info=True) + + +class RuntimeRegistry: + """In-process registry — the whole chat runtime is single-process state. + + Live agents, their event queues, turn locks and pending permission Futures + all live in this process's memory. Running uvicorn with multiple workers + would scatter requests across processes that cannot see each other's + runtimes (a /api/chat/permission answer landing on the wrong worker can + never resolve the ask). Keep `--workers 1` (uvicorn's default); the + multi-worker upgrade path is sticky session routing or a dedicated + agent-runner process — see docs/HANDOFF.md §3.3.""" + + def __init__(self) -> None: + self._runtimes: dict[str, SessionRuntime] = {} + self._create_lock = asyncio.Lock() + self._loop: asyncio.AbstractEventLoop | None = None + + # -- running state ------------------------------------------------------- + + def peek(self, session_id: str) -> "SessionRuntime | None": + """The live runtime, if any — without building one (for attach).""" + return self._runtimes.get(session_id) + + def is_running(self, session_id: str) -> bool: + """Whether the session has a turn in flight (live or background).""" + rt = self._runtimes.get(session_id) + return ( + rt is not None + and rt.turn_lock.locked() + and not rt.run_task.done() + ) + + def running_sessions(self) -> list[str]: + return [sid for sid in list(self._runtimes) if self.is_running(sid)] + + async def get(self, project, session) -> SessionRuntime: + """Return a live runtime for the session, (re)building if the driver has + exited (e.g. after an error) or the active model changed (in-conversation + model switch) so a fresh agent restores from SessionLog with the new model.""" + from app.backends.ms_agent import model_link + + async with self._create_lock: + self._loop = asyncio.get_running_loop() # for cross-thread toggles + rt = self._runtimes.get(session.id) + # Rebuild on model switch, but never mid-turn: an in-flight turn holds + # turn_lock, so defer the swap to the next idle turn to avoid cancelling it. + model_changed = ( + rt is not None + and not rt.turn_lock.locked() + and rt.model_key != model_link.active_model() + ) + if rt is not None and not rt.run_task.done() and not model_changed: + return rt + if rt is not None: + await rt.aclose() + rt = SessionRuntime(project, session, await self._resolve_mcp(project)) + self._runtimes[session.id] = rt + return rt + + async def _resolve_mcp(self, project) -> dict: + """Resolve enabled MCP servers and probe them (connect+initialize), so an + unreachable/invalid server is dropped before it can break the chat turn.""" + from ms_agent.tui.managed_config import resolve_mcp_config + + from app.backends.ms_agent import mcp_health + from app.backends.ms_agent.common import home + + raw = (resolve_mcp_config(home(), project.path, None) or {}).get("mcpServers", {}) + healthy = await mcp_health.filter_healthy(raw) + return {"mcpServers": healthy} if healthy else {} + + async def _apply_mcp_toggle(self, name: str, enabled: bool) -> None: + for rt in list(self._runtimes.values()): + mcp_rt = getattr(rt.agent, "mcp_runtime", None) + if mcp_rt is None or mcp_rt.get_server(name) is None: + continue + try: + if enabled: + await mcp_rt.enable_server(name) + else: + await mcp_rt.disable_server(name) + except Exception: + logger.warning("live MCP toggle failed: %s", name, exc_info=True) + + def toggle_mcp(self, name: str, enabled: bool) -> None: + """Sync entry (for sync management routes running in the threadpool): + connect/disconnect a server on any live session that manages it. + Best-effort and non-fatal — persistence is the source of truth.""" + loop = self._loop + if loop is None or not self._runtimes: + return # no live session to affect; change applies on next build + try: + future = asyncio.run_coroutine_threadsafe( + self._apply_mcp_toggle(name, enabled), loop + ) + future.result(timeout=15) + except Exception: + logger.warning("scheduling live MCP toggle failed: %s", name, exc_info=True) + + def resolve_permission(self, session_id: str, request_id: str, action: str) -> bool: + """Answer a pending restricted-mode ask on the session's live runtime. + + Returns False when there is no live runtime or the request is unknown / + already resolved (e.g. it timed out to deny).""" + rt = self._runtimes.get(session_id) + handler = getattr(rt, "permission_handler", None) if rt else None + if handler is None: + return False + future = handler._pending.get(request_id) + if future is None or future.done(): + return False + from ms_agent.permission.handler import PermissionAction, PermissionResponse + + try: + handler.resolve(request_id, PermissionResponse(action=PermissionAction(action))) + except ValueError: + return False + return True + + def set_project_permission_mode(self, project_id: str, mode: str) -> int: + """Hot-apply a project's permission mode to its LIVE runtimes. + + The SDK's ``set_permission_mode`` swaps the enforcer's frozen config in + place, so the next tool call obeys the new mode without rebuilding the + agent (an in-flight turn is unaffected until its next call). Runtimes + built later pick the mode up from the project sidecar at build time. + Returns how many runtimes were updated. + """ + n = 0 + for rt in self._runtimes.values(): + if getattr(rt.project, "id", None) != project_id: + continue + agent = getattr(rt, "agent", None) + if agent is None or not hasattr(agent, "set_permission_mode"): + continue + try: + agent.set_permission_mode(mode) + n += 1 + except Exception: # never let a mode toggle break a live session + logger.debug("permission mode hot-apply skipped", exc_info=True) + return n + + async def interrupt(self, session_id: str) -> bool: + """Explicit stop (POST /api/chat/interrupt): discard the live runtime so + the in-flight SDK generation is cancelled now, then seal the log so the + rebuilt agent answers the NEXT message, not the interrupted one. Returns + False when there is no live runtime for the session. + + This is the *only* path that cancels a turn. A plain client disconnect + (navigating away) does not come here — it drains in the background and + the conversation keeps running (see chat._drain_abandoned_turn).""" + from app.backends.ms_agent.chat import _seal_interrupted_turn + + # Hold _create_lock across the WHOLE stop (pop + cancel + seal), not just + # the pop. Otherwise a next-message get() for this same session could + # build a SECOND runtime on the same SessionLog while we are still + # sealing — the new runtime then clobbers the interrupted turn's history + # (reproduced: the whole interrupted turn vanished from the log). With + # the lock held, that get() waits and rebuilds from the sealed log. + async with self._create_lock: + rt = self._runtimes.pop(session_id, None) + if rt is None: + return False + await rt.aclose() # cancels the driver (SDK interrupt closes upstream) + try: + _seal_interrupted_turn(rt) + except Exception: # never let sealing crash the stop + logger.debug("seal on interrupt skipped", exc_info=True) + return True + + async def close(self, session_id: str) -> None: + async with self._create_lock: + rt = self._runtimes.pop(session_id, None) + if rt is not None: + await rt.aclose() + + def discard(self, session_id: str) -> None: + """Sync best-effort stop (for use from sync routes, e.g. session delete): + schedule the driver close on its owning event loop.""" + loop = self._loop + if loop is not None and loop.is_running(): + try: + if asyncio.get_running_loop() is loop: + loop.create_task(self.close(session_id)) + return + except RuntimeError: + pass + try: + future = asyncio.run_coroutine_threadsafe(self.close(session_id), loop) + future.result(timeout=15) + return + except Exception: + logger.warning("scheduling runtime discard failed: %s", session_id, exc_info=True) + + rt = self._runtimes.pop(session_id, None) + if rt is not None and not rt.run_task.done(): + try: + rt.run_task.cancel() + except RuntimeError: + logger.debug("runtime discard skipped for stopped loop", exc_info=True) + + async def close_all(self) -> None: + for sid in list(self._runtimes): + await self.close(sid) + + +registry = RuntimeRegistry() diff --git a/webui/backend/app/backends/ms_agent/sessions.py b/webui/backend/app/backends/ms_agent/sessions.py new file mode 100644 index 000000000..becf5187a --- /dev/null +++ b/webui/backend/app/backends/ms_agent/sessions.py @@ -0,0 +1,974 @@ +"""Sessions adapter — SessionManager (+ cross-project find) + sidecar preview.""" +from __future__ import annotations + +import hashlib +import json +import os +import re +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import quote + +from app.backends.errors import NotFound +from app.backends.ms_agent import sidecar +from app.backends.ms_agent.common import ( + autoname_session, + find_session, + pm, + resolve_project, + sm_for, +) +from app.backends.ms_agent.mapping import session_to_schema +from app.core.filetypes import guess_type +from app.schemas.session import ( + Artifact, + Session, + SessionCreate, + SessionFile, + SessionMessage, + SessionPart, + SessionPlan, + SessionStep, + SessionTask, + SessionUpdate, +) + + +def list_sessions(project_id: str | None = None) -> list[Session]: + manager = pm() + if project_id: + proj = manager.get(project_id) + projects = [proj] if proj is not None else [] + else: + projects = manager.list() + + out: list[Session] = [] + for proj in projects: + for s in sm_for(proj).list(): + # backfill a display title for still-default sessions that have history + out.append(autoname_session(proj, s)) + out.sort(key=lambda s: s.updated_at, reverse=True) + return [_with_running(session_to_schema(s)) for s in out] + + +def _with_running(schema: Session) -> Session: + """Stamp the live turn-in-flight flag (registry state; lazy import keeps + mapping.py free of a runtime dependency).""" + from app.backends.ms_agent.runtime import registry + + schema.running = registry.is_running(schema.id) + return schema + + +def create_session(body: SessionCreate) -> Session: + try: + project = resolve_project(body.project_id) + except KeyError: + project = resolve_project( + None) # unknown id -> default (mock is lenient) + session = sm_for(project).create(name=body.title) + if body.preview: + sidecar.merge("sessions", session.id, {"preview": body.preview}) + return session_to_schema(session) + + +def get_session(sid: str) -> Session: + found = find_session(sid) + if not found: + raise NotFound("session not found") + _project, session, _sm = found + return _with_running(session_to_schema(session)) + + +def _history_step( + tc: dict, + errored: dict[str, str], + results: dict[str, str], + durations: dict[str, int], + project=None, +) -> SessionStep | None: + """Map one persisted assistant tool_call to a step card, or None to drop it. + + Reuses the live event mapping so history renders the same step cards. The + log shape differs from the live event: the tool name is under ``tool_name`` + (or a nested ``function.name``) and ``arguments`` is a raw JSON string. The + tool result is looked up by call id in ``results`` (and ``errored`` for a + failed call), so the detail drawer shows the full invocation like a live + step; ``durations`` supplies the persisted elapsed time (``duration_ms``); + a failed call is marked ``status="error"``. + + For ``file_read``/``file_write``/``file_edit`` cards, ``meta["exists"]`` + records whether the referenced workspace file is still present so the + frontend can open it (or render a non-clickable "deleted" card when it's + gone). + """ + from app.backends.ms_agent.chat import _as_dict, _tool_step_meta + + fn = tc.get("function") if isinstance(tc.get("function"), dict) else {} + name = tc.get("tool_name") or fn.get("name") or "" + args = _as_dict(tc.get("arguments", fn.get("arguments"))) + meta = _tool_step_meta(str(name), args) + if meta is None: + return None + kind = str(meta.pop("kind")) + # Full invocation for the detail drawer (mirrors the live _tool_step). + meta["tool"] = str(name) + meta["arguments"] = args + call_id = str(tc.get("id") or "") + if call_id and call_id in results: + meta["result"] = results[call_id] + if call_id and call_id in durations: + meta["duration_ms"] = durations[call_id] + if call_id and call_id in errored: + meta["status"] = "error" + meta["error"] = errored[call_id] + # A DENIED call is already rendered by its persisted permission record + # (the rejected authorization card) — drop this redundant tool step so + # history doesn't show two identical "rejected" cards. + if "denied" in str(errored[call_id]).lower(): + return None + # Errored/interrupted file steps keep `tool_call` kind so the frontend + # renders them with the rich accordion (ToolCallStepCard) showing arguments + # and error state, rather than the simplified "已修改" one-liner. + if meta.get("status") == "error" and kind in ("file_read", "file_write", + "file_edit"): + kind = "tool_call" + if kind in ("file_read", "file_write", + "file_edit") and project is not None: + path = str(meta.get("path") or "") + if path: + try: + meta["exists"] = (Path(project.path) / path).is_file() + except OSError: + meta["exists"] = False + return SessionStep(kind=kind, meta=meta) + + +def _permission_step(row: dict) -> SessionStep: + """Build a replayed (read-only) authorization card from a persisted + permission record. Mirrors the live ``chat._permission_step`` meta shape, + minus the live-only request_id/session_id (a replayed card is resolved, so + it renders its ``state`` and shows no buttons).""" + tool = str(row.get("tool_name") or "") + args = row.get("arguments") if isinstance(row.get("arguments"), + dict) else {} + preview = json.dumps(args, ensure_ascii=False) + if len(preview) > 160: + preview = preview[:160] + "…" + from app.backends.ms_agent.chat import _tool_source + + return SessionStep(kind="authorization", + meta={ + "state": str(row.get("state") or "approved"), + "tool_name": tool, + "arguments": args, + "desc": f"{tool} {preview}".strip(), + "source": _tool_source(tool), + }) + + +def _plan_entries(tc: dict, results: dict[str, str]) -> list | None: + """Extract the plan (todo) items from a persisted ``todo_list---todo_write`` + call, or None if this call isn't a plan write. + + Prefers the tool RESULT's ``todos`` (the authoritative full plan after the + tool merges status updates), falling back to the call arguments. This lets + history rebuild the plan the same way the live ``plan_updated`` event does. + """ + from app.backends.ms_agent.chat import _as_dict + + fn = tc.get("function") if isinstance(tc.get("function"), dict) else {} + name = str(tc.get("tool_name") or fn.get("name") or "") + base, _, leaf = name.partition("---") + if base != "todo_list" or leaf != "todo_write": + return None + call_id = str(tc.get("id") or "") + if call_id in results: + try: + data = json.loads(results[call_id]) + except (ValueError, TypeError): + data = None + if isinstance(data, dict) and isinstance(data.get("todos"), list): + return data["todos"] + todos = _as_dict(tc.get("arguments", fn.get("arguments"))).get("todos") + return todos if isinstance(todos, list) else None + + +def _plan_part(entries: list) -> SessionPart: + """Build the single ``tasks`` plan part from todo entries, mapping each + todo status to the frontend task status (mirrors chat.py::_tasks).""" + from app.backends.ms_agent.chat import _PLAN_STATUS + + tasks: list[SessionTask] = [] + for i, entry in enumerate(entries): + entry = entry if isinstance(entry, dict) else {"content": str(entry)} + tasks.append( + SessionTask( + id=str(i), + label=str(entry.get("content", "")), + status=_PLAN_STATUS.get(str(entry.get("status", "pending")), + "pending"), + )) + return SessionPart(kind="tasks", tasks=tasks) + + +def _disk_plan(plan_path: str) -> list | None: + """The todos in a ``plan.json`` — the agent's live plan file, which also + captures any manual edits — or None if absent/unparseable.""" + try: + with open(plan_path, encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError): + return None + todos = data.get("todos") if isinstance(data, dict) else None + return todos if isinstance(todos, list) else None + + +def read_plan(session_id: str) -> SessionPlan: + """The pinned composer plan box reflects the session's live ``plan.json`` + — the plan file written by the todo_list tool and by any manual edits. It + deliberately ignores the chat/session log. + + Plans are SESSION-scoped: ``build_agent`` points the todo tool at + ``/plan.json`` so concurrent sessions in one project no longer + clobber each other's plans. Sessions created before that change fall back + to the legacy project-shared ``/plan.json``. Empty when + neither exists or the file can't be parsed. + + ``active`` = a turn is in flight AND the plan file was written during it + (mtime vs the runtime's wall-clock turn origin) — the server-side truth + the frontend uses to animate "running" rows, stable across reloads and + tab switch-backs.""" + found = find_session(session_id) + if not found: + return SessionPlan() + project, session, _ = found + from app.backends.ms_agent.config import session_dir + + plan_path = os.path.join(session_dir(project, session), "plan.json") + todos = _disk_plan(plan_path) + if todos is None: # pre-isolation sessions used a project-shared plan + plan_path = os.path.join(project.path, "plan.json") + todos = _disk_plan(plan_path) + if todos is None: + return SessionPlan() + + active = False + try: + from app.backends.ms_agent.runtime import registry + + if registry.is_running(session_id): + rt = registry.peek(session_id) + started_wall = getattr(rt, "turn_started_wall", None) + if started_wall is not None: + # 1s slack: the tool may write the file in the same tick the + # origin is stamped. + active = os.path.getmtime(plan_path) >= started_wall - 1.0 + except Exception: + active = False + return SessionPlan(tasks=_plan_part(todos).tasks, active=active) + + +def list_messages(sid: str) -> list[SessionMessage]: + found = find_session(sid) + if not found: + raise NotFound("session not found") + project, session, sm = found + rows: list[dict] = [] + extra: list[dict] = [] + try: + log = sm.get_session_log(session) + rows = log.get_all_messages() + # Display-only records (excluded from the LLM context) are read + # separately and merged back into the timeline by seq: error records + # (API/turn errors) and permission records (restricted-mode auth cards). + if hasattr(log, "get_errors"): + extra += log.get_errors() + if hasattr(log, "get_permissions"): + extra += log.get_permissions() + if hasattr(log, "get_skill_invocations"): + extra += log.get_skill_invocations() + if hasattr(log, "get_loop_ends"): + extra += log.get_loop_ends() + except Exception: + rows, extra = [], [] + stream = sorted([*rows, *extra], key=lambda r: r.get("seq", 0)) + return _reconstruct(stream, project) + + +# Marker prefixed to the enqueued prompt by chat._compose_prompt when the user +# attached files. Reconstruction splits it back out so replay shows file cards +# instead of the raw path list. +_ATTACHED_MARKER = "[Attached files]" + + +def _split_attached(content: str) -> tuple[str, list[str]]: + """Split a persisted user message into (display_text, attached_paths). + + The attachment block (see chat._compose_prompt) lists ``- `` lines + after the marker. Everything before the marker is the user's typed text. + """ + idx = content.find(_ATTACHED_MARKER) + if idx == -1: + return content, [] + text = content[:idx].strip() + paths: list[str] = [] + for line in content[idx:].splitlines(): + line = line.strip() + if line.startswith("- "): + p = line[2:].strip() + if p: + paths.append(p) + return text, paths + + +def _file_kind(name: str) -> str: + ct = guess_type(name) or "" + if ct.startswith("image/"): + return "image" + if ct.startswith("audio/"): + return "audio" + if ct.startswith("video/"): + return "video" + return "file" + + +def _raw_url(pid: str, path: str) -> str: + enc = "/".join(quote(seg) for seg in path.split("/")) + return f"/api/projects/{quote(pid)}/workspace/files/{enc}/raw" + + +def _attached_files(project, paths: list[str]) -> list[SessionFile]: + root = Path(project.path) + out: list[SessionFile] = [] + for p in paths: + size: int | None = None + try: + fp = root / p + exists = fp.is_file() + if exists: + size = fp.stat().st_size + except OSError: + exists = False + out.append( + SessionFile( + name=p.split("/")[-1], + path=p, + url=_raw_url(project.id, p), + type=_file_kind(p), + size=size, + exists=exists, + )) + return out + + +def _reconstruct(rows: list[dict], project=None) -> list[SessionMessage]: + """Rebuild ordered display messages from the append-only log. + + One logical assistant turn spans several rows (tool-call rows carrying + placeholder text + a final answer row + interleaved tool results). Collapse + them into a single assistant bubble whose ``parts`` preserve the real order + of answer text and the tool/step timeline: an assistant row's persisted + ``reasoning_content`` becomes a ``thought`` part (in row order, before that + row's answer/steps); answer-text rows become ``text`` parts; each tool_call + row becomes its own linear ``step`` part in stream order (no task nesting). + A failed tool result (``role="tool"`` + ``is_error``) marks its step + ``status="error"``. A ``_type="error"`` record (API/turn error, excluded + from model context) becomes its own ``error`` message. Rows re-appended by + context compaction (``_source="compaction"`` — the squeezed LLM view, incl. + the synthetic summary row) are skipped: history shows the original + timeline. ``system`` and plain ``tool`` rows are otherwise dropped. + """ + from app.backends.ms_agent.chat import is_placeholder_content + + # Failed tool results keyed by call id, so the matching tool_call step can be + # marked errored (the assistant tool_call row precedes its tool result row). + errored: dict[str, str] = { + str(r.get("tool_call_id") or ""): str(r.get("content") or "") + for r in rows if r.get("role") == "tool" and r.get("is_error") + } + # All tool results by call id, so a step can show its full result content. + results: dict[str, str] = { + str(r.get("tool_call_id") or ""): str(r.get("content") or "") + for r in rows if r.get("role") == "tool" + } + # Persisted tool elapsed time by call id (display only), for the step card. + durations: dict[str, int] = { + str(r.get("tool_call_id") or ""): int(r.get("duration_ms")) + for r in rows if r.get("role") == "tool" + and isinstance(r.get("duration_ms"), (int, float)) + } + + # Workspace root + the session's plan-file locations (reported by the todo + # tool in these rows), for classifying file writes into the changed-files + # summary: out-of-workspace writes and plan files are not deliverables. + ws = _ws_root(project) + plan_paths = plan_paths_in_rows(rows, ws) if ws else None + + messages: list[SessionMessage] = [] + parts: list[SessionPart] = [] + # Tool-round group id: one assistant row's tool_calls array = one round + # (mirrors the live mapper's `group` stamping on step metas). + group_seq = 0 + # Skill-invocation marker of a slash/structured skill turn: carries the + # user's ORIGINAL text + picked skill ids, shown in place of the expanded + # prompt on the user row that follows (and echoed as segments). + pending_skill: dict | None = None + # Restricted-mode authorization records awaiting their matching tool_call + # step. They persist EAGERLY at ask() time (mid-round), so their seq + # predates the assistant reasoning/tool_call rows persisted at the round + # boundary — replaying them at their seq position would wrongly place the + # auth card before the turn's thought, splitting it from its tool step. We + # instead buffer them and insert each card immediately before the step it + # authorized (matched by tool + arguments), so replay order matches live + # (thought → auth → tool step, adjacent). Unmatched ones flush at turn end. + pending_perms: list[dict] = [] + # Workspace paths written/edited in the current turn's tool-call loop, for + # the assistant message's changed-files summary (frontend collapse). + turn_changed: list[str] = [] + turn_changed_seen: set[str] = set() + # Wall-clock loop duration from this turn's persisted loop_end marker (the + # one field not derivable from message rows). None until the marker is seen. + turn_duration_ms: int | None = None + # Absolute path of the session plan markdown (loop_end marker's + # ``plan_file``), set when the turn rewrote the todo list. + turn_plan_file: str | None = None + + def _perm_key(tool: str, args) -> str: + # Normalize dict or JSON-string arguments to a canonical form so a + # permission record matches its tool_call regardless of shape. + if isinstance(args, str): + try: + args = json.loads(args) + except (ValueError, TypeError): + args = {} + if not isinstance(args, dict): + args = {} + return tool + "\x1f" + json.dumps( + args, sort_keys=True, ensure_ascii=False) + + def _take_matching_perm(tool: str, args, call_id: str = "") -> dict | None: + # Prefer an exact tool_call-id match (unambiguous even when a round + # fires several identical calls in parallel); fall back to + # (tool, args) FIFO for records that predate call_id (old logs) or when + # the adapter hadn't assigned an id at ask time. + if call_id: + for i, rec in enumerate(pending_perms): + if str(rec.get("call_id") or "") == call_id: + return pending_perms.pop(i) + # Fallback: (tool, args) FIFO over whatever remains — covers old logs, + # empty ids, and id-bearing records whose call had no id at match time. + key = _perm_key(tool, args) + for i, rec in enumerate(pending_perms): + if _perm_key(str(rec.get("tool_name") or ""), + rec.get("arguments")) == key: + return pending_perms.pop(i) + return None + + def flush() -> None: + nonlocal parts, pending_perms, turn_changed, turn_changed_seen + nonlocal turn_duration_ms, turn_plan_file + # Any authorization that never matched a tool step (unusual) still + # renders, appended in record order so nothing is dropped. + for rec in pending_perms: + parts.append(SessionPart(kind="step", step=_permission_step(rec))) + pending_perms = [] + content = "\n\n".join(p.text for p in parts + if p.kind == "text" and p.text).strip() + if content or any(p.kind in ("step", "thought", "tasks", "interrupted") + for p in parts): + messages.append( + SessionMessage(role="assistant", + content=content, + parts=parts, + changed_files=turn_changed, + duration_ms=turn_duration_ms, + plan_file=turn_plan_file)) + parts = [] + turn_changed = [] + turn_changed_seen = set() + turn_duration_ms = None + turn_plan_file = None + + def append_text(text: str) -> None: + # Merge consecutive answer rows into the current text block; start a new + # block if a step was emitted in between (preserving stream order). + if parts and parts[-1].kind == "text": + prev = parts[-1].text + parts[-1].text = f"{prev}\n\n{text}" if prev else text + else: + parts.append(SessionPart(kind="text", text=text)) + + for row in rows: + if row.get("_source") == "compaction": + # Compacted-view re-appends duplicate earlier rows for the LLM + # window only; replaying them would double the timeline. + continue + if row.get("_type") == "loop_end": + # Persisted loop boundary: carries the wall-clock duration for this + # turn (its seq lands after the turn's rows, before the next user + # row, so it applies to the turn being accumulated). changed_files + # is still derived below (robust); duration and the plan-file + # location are taken from the marker. + d = row.get("duration_ms") + if isinstance(d, (int, float)): + turn_duration_ms = int(d) + pf = row.get("plan_file") + if isinstance(pf, str) and pf: + turn_plan_file = pf + continue + if row.get("_type") == "error": + flush() + msg = str(row.get("message") or "") + messages.append( + SessionMessage( + role="assistant", + content=msg, + parts=[ + SessionPart( + kind="error", + text=msg, + recoverable=bool(row.get("recoverable", False)), + ) + ], + )) + continue + if row.get("_type") == "permission": + # Buffer until its matching tool_call step is emitted (see + # pending_perms above); keeps the auth card adjacent to the tool it + # authorized, matching the live frame order. + pending_perms.append(row) + continue + if row.get("_type") == "skill_invocation": + # A slash-skill turn persists the EXPANDED prompt as the user row + # (that is what the model must see); this marker precedes it and + # carries what the user actually typed + the picked skill ids + # (and, for configuration-style turns, the segments as sent). + pending_skill = { + "original_text": + str(row.get("original_text") or ""), + "skill_ids": + [str(s) for s in (row.get("skill_ids") or []) if s], + "segments": [ + s for s in (row.get("segments") or []) + if isinstance(s, dict) + ], + } + continue + role = row.get("role") + if role == "user": + flush() + content = row.get("content") + if content is not None: + display = (pending_skill + or {}).get("original_text") or str(content) + skill_ids = (pending_skill or {}).get("skill_ids") or [] + sent_segments = (pending_skill or {}).get("segments") or [] + pending_skill = None + text, paths = _split_attached(display) + files = (_attached_files(project, paths) + if paths and project is not None else []) + # Configuration-style echo: prefer the segments AS SENT by the + # composer (skill id + display name + text); fall back to + # rebuilding from bare skill ids for older records. + if sent_segments: + segments = sent_segments + elif skill_ids: + segments = [{ + "type": "skill", + "id": sid + } for sid in skill_ids] + ([{ + "type": "text", + "text": text.strip() + }] if text.strip() else []) + else: + segments = [] + messages.append( + SessionMessage( + role="user", + content=text, + files=files, + segments=segments, + )) + elif role == "assistant": + # An interrupted round's unsigned partial reasoning is persisted + # under a display-only key (replaying it would 400 on Anthropic); + # it renders as a normal finished thought block. + reasoning = row.get("reasoning_content") or row.get( + "interrupted_reasoning") + if reasoning: + # One persisted reasoning block per assistant row, placed before + # that row's answer text / tool steps (stream order); its elapsed + # time replays as "thought Ns". + dur = row.get("reasoning_duration") + parts.append( + SessionPart( + kind="thought", + text=str(reasoning), + duration=int(dur) if isinstance(dur, + (int, + float)) else None, + )) + tool_calls = row.get("tool_calls") + if tool_calls: + # This row's content is the model's mid-turn narration: the + # live stream showed it, so replay does too — verbatim. Only an + # empty string (→ empty text part) or framework filler is + # skipped (same rule as the no-tool-call branch below, so a + # ``content_placeholder`` row is honored whatever its shape). + narration = str(row.get("content") or "") + if narration and not is_placeholder_content(row): + append_text(narration) + # SERVER-SIDE grouping truth: this assistant row's tool_calls + # array IS one tool round — every step it yields shares one + # `group` id, so the frontend nests them under one accordion + # (matching the live mapper's group stamping). + group_seq += 1 + # Intermediate step; its content is a placeholder, not an answer. + for tc in tool_calls: + if not isinstance(tc, dict): + continue + # Accumulate this loop's written/edited files (plus + # "plan.md" for todo writes) for the turn's changed-files + # summary. + wpath = _changed_entry(tc, ws, plan_paths) + if wpath and wpath not in turn_changed_seen: + turn_changed_seen.add(wpath) + turn_changed.append(wpath) + # A todo_write is plan machinery: append a plan SNAPSHOT + # at this point in the timeline (never a step card, and + # never refreshed in place) — mirrors the live stream, where + # every plan update adds a new frozen block. The composer's + # pinned panel is the live/aggregated view. + entries = _plan_entries(tc, results) + if entries is not None: + parts.append(_plan_part(entries)) + continue + # Any other tool call becomes its own linear step part + # (``durations`` supplies the persisted tool elapsed time). + # Consume the matching restricted-mode ask FIRST (before the + # step-None check): a DENIED call drops its step, but the + # rejected auth card must still render at the call's original + # position (not flushed to the turn end). Pair by call_id + # (exact even for parallel identical calls), falling back to + # (tool, args) FIFO for old logs / empty ids. + tool_name = str( + tc.get("tool_name") + or (tc.get("function") or {}).get("name") or "") + perm = _take_matching_perm(tool_name, + tc.get("arguments"), + call_id=str(tc.get("id") or "")) + if perm is not None: + pstep = _permission_step(perm) + pstep.meta["group"] = group_seq + parts.append(SessionPart(kind="step", step=pstep)) + step = _history_step(tc, errored, results, durations, + project) + if step is not None: + step.meta["group"] = group_seq + parts.append(SessionPart(kind="step", step=step)) + else: + content = row.get("content") + # A synthetic filler content (the interrupt seal's neutral + # placeholder) only exists to close the turn for the model; the + # UI shows the interrupted badge (below) instead of the literal. + if content and not is_placeholder_content(row): + append_text(str(content)) + if row.get("interrupted"): + # Faithful-interrupt marker: the row carries its partial content + # verbatim (rendered above); the badge marks the exact stop + # point so replay matches what the live view showed. + parts.append(SessionPart(kind="interrupted")) + # system / plain tool rows are not rendered (tool errors handled above). + + flush() + return messages + + +def delete_session(sid: str) -> None: + from app.backends.ms_agent.runtime import registry + + found = find_session(sid) + if not found: + raise NotFound("session not found") + _project, _session, sm = found + registry.discard(sid) # stop any live agent before removing its log + sm.delete(sid) + sidecar.drop("sessions", sid) + + +def update_session(sid: str, body: SessionUpdate) -> Session: + """Rename a session (update its title).""" + found = find_session(sid) + if not found: + raise NotFound("session not found") + project, session, _sm = found + if body.title is not None: + sm_for(project).update(sid, name=body.title) + session = sm_for(project).get(sid) + return session_to_schema(session) + + +def _artifact_id(path: str) -> str: + return "art_" + hashlib.sha1(path.encode("utf-8")).hexdigest()[:12] + + +def _ws_root(project) -> str | None: + """The project workspace root — the working directory file-tool relative + paths resolve against (``project.path``; mounted dir for mounted projects, + the internal project dir otherwise).""" + try: + p = str(getattr(project, "path", "") or "") + return os.path.normpath(p) if p else None + except Exception: + return None + + +def _resolve_tool_path(workspace: str, path: str) -> str: + """Absolute, normalized location of a file-tool path argument (relative + paths join the workspace root — the tools' working directory).""" + p = path if os.path.isabs(path) else os.path.join(workspace, path) + return os.path.normpath(p) + + +def _workspace_rel(workspace: str, abspath: str) -> str | None: + """``abspath`` as a workspace-relative path, or None when it lies OUTSIDE + the workspace (session dir, ``..`` escapes, absolute paths elsewhere) — + such writes are session/system state, not workspace deliverables.""" + root = os.path.normpath(workspace) + if not abspath.startswith(root + os.sep): + return None + return os.path.relpath(abspath, root) + + +_RENDERED_MD_RE = re.compile(r"^OK: rendered plan markdown to (.+)$") + + +def plan_paths_in_rows(rows: list[dict], workspace: str) -> set[str]: + """Absolute locations of this session's PLAN files, as reported by the + todo tool itself — no filename heuristics: a plan named ``xxx_plan.md`` + (or anything else) is recognized because the tool call said so, not + because of how it is named. Sources: + + - ``todo_write`` results carry ``plan_path`` (the plan json, relative to + the tool's output dir); its auto-rendered same-stem ``.md`` twin counts + too. + - ``todo_render_md`` results name the markdown file they produced (the + model may point it anywhere, e.g. into the workspace under any name). + - persisted ``loop_end`` markers carry the resolved ``plan_file``. + + Used to keep plan files out of the file ledgers (changed_files summary, + session artifacts → the composer's file list).""" + out: set[str] = set() + for row in rows: + if not isinstance(row, dict): + continue + pf = row.get("plan_file") + if row.get("_type") == "loop_end" and isinstance(pf, str) and pf: + out.add(os.path.normpath(pf)) + continue + if row.get("role") != "tool": + continue + content = str(row.get("content") or "").strip() + m = _RENDERED_MD_RE.match(content) + if m: + out.add(_resolve_tool_path(workspace, m.group(1).strip())) + continue + if '"plan_path"' not in content: + continue + try: + data = json.loads(content) + except (ValueError, TypeError): + continue + pp = data.get("plan_path") if isinstance(data, dict) else None + if isinstance(pp, str) and pp: + ap = _resolve_tool_path(workspace, pp) + out.add(ap) + stem, ext = os.path.splitext(ap) + if ext == ".json": + out.add(stem + ".md") + return out + + +def latest_rendered_plan_md(rows: list[dict], workspace: str) -> str | None: + """Absolute path of the LAST markdown the todo tool rendered in ``rows`` + (a ``todo_render_md`` result), or None. The most recently created/updated + plan artifact is the one the plan chip should point at.""" + latest: str | None = None + for row in rows: + if not isinstance(row, dict) or row.get("role") != "tool": + continue + m = _RENDERED_MD_RE.match(str(row.get("content") or "").strip()) + if m: + latest = _resolve_tool_path(workspace, m.group(1).strip()) + return latest + + +def _written_path(tc: dict) -> str | None: + """The workspace path a ``file_system---write_file/edit_file`` tool_call + targets, or None if this call isn't a file write/edit. Shared by the + artifact ledger and the per-loop changed-files summary.""" + from app.backends.ms_agent.chat import _PATH_KEYS, _as_dict + + if not isinstance(tc, dict): + return None + fn = tc.get("function") if isinstance(tc.get("function"), dict) else {} + name = str(tc.get("tool_name") or fn.get("name") or "") + base, _, leaf = name.partition("---") + if base != "file_system" or leaf not in ("write_file", "edit_file"): + return None + args = _as_dict(tc.get("arguments", fn.get("arguments"))) + return next( + (args[k] + for k in _PATH_KEYS if isinstance(args.get(k), str) and args[k]), + None, + ) + + +def _is_plan_write(tc: dict) -> bool: + """True for a todo tool call that created/updated the session plan files + (``todo_write`` rewrites them; ``todo_render_md`` renders the markdown), + so the loop's changed-files summary carries the reserved ``plan.md`` + marker (the plan is session state, not a workspace file — its content is + served by ``GET /sessions/{id}/plan``).""" + if not isinstance(tc, dict): + return False + fn = tc.get("function") if isinstance(tc.get("function"), dict) else {} + name = str(tc.get("tool_name") or fn.get("name") or "") + return name in ("todo_list---todo_write", "todo_list---todo_render_md") + + +def _changed_entry( + tc: dict, + workspace: str | None = None, + plan_paths: set[str] | None = None, +) -> str | None: + """This tool_call's contribution to the changed-files summary: the + workspace-relative path of a file write/edit, the reserved ``"plan.md"`` + marker for a plan write, else None. With a ``workspace`` root, file + writes that resolve OUTSIDE it (e.g. the model copying its plan into the + session dir via ``..`` or an absolute path) or onto a known plan file + (``plan_paths``) are excluded — they are plan/session state, not + workspace deliverables.""" + if _is_plan_write(tc): + return "plan.md" + path = _written_path(tc) + if not path: + return None + if workspace is None: + return path + ap = _resolve_tool_path(workspace, path) + if plan_paths and ap in plan_paths: + return None + return _workspace_rel(workspace, ap) + + +def changed_files_in_rows( + rows: list[dict], + workspace: str | None = None, + plan_paths: set[str] | None = None, +) -> list[str]: + """Files changed across the given assistant rows, in first-write order, + deduped: workspace-relative paths written/edited plus the reserved + ``plan.md`` marker when the todo plan was rewritten. With a ``workspace`` + root, out-of-workspace writes and plan files (``plan_paths``, derived + from the rows when omitted) are filtered out. The per-loop equivalent of + the session-wide artifact ledger — used to summarize a completed + tool-call loop (frontend collapses the intermediate steps and shows this + changed-files set).""" + if workspace is not None and plan_paths is None: + plan_paths = plan_paths_in_rows(rows, workspace) + ordered: list[str] = [] + seen: set[str] = set() + for row in rows: + if row.get("role") != "assistant": + continue + for tc in row.get("tool_calls") or []: + path = _changed_entry(tc, workspace, plan_paths) + if path and path not in seen: + seen.add(path) + ordered.append(path) + return ordered + + +def list_artifacts(sid: str) -> list[Artifact]: + """Per-conversation artifact ledger: every workspace file the agent WROTE or + EDITED during this session, in first-write order, deduped by path. + + Derived from the immutable, append-only SessionLog tool-call records (not a + live directory scan), so it reflects what the conversation produced + regardless of what the user later did to those files: a file the agent wrote + but the user then deleted stays listed as ``deleted=True`` (the design's + greyed "已删除" card), and a later user edit does not remove the entry. + + v1 only tracks the ``file_system`` write/edit tools, which carry an explicit + path argument. Files created indirectly by ``code_executor``/shell are not + captured here (no reliable path in the call); a workspace-snapshot diff over + the SDK's ``.ms_agent/snapshots/`` repo is the planned follow-up. + """ + found = find_session(sid) + if not found: + raise NotFound("session not found") + project, session, sm = found + try: + rows = sm.get_session_log(session).get_all_messages() + except Exception: + rows = [] + + # The ledger only lists WORKSPACE deliverables: writes resolving outside + # the workspace (e.g. the model copying its plan into the session dir via + # ``..`` or an absolute path) and the session's plan files (identified by + # the todo tool's own reports — any configured filename) are excluded, so + # the composer's file list stays plan-free and workspace-scoped. Entries + # are normalized workspace-relative paths, first-write order, deduped. + ws = _ws_root(project) or str(project.path) + plan_paths = plan_paths_in_rows(rows, ws) + # Belt and braces: the session's own plan files at the webui default + # location, in case no todo result made it into the log. + try: + from app.backends.ms_agent.config import session_dir + + sdir = session_dir(project, session) + plan_paths |= { + os.path.normpath(os.path.join(sdir, n)) + for n in ("plan.json", "plan.md") + } + except Exception: + pass + + ordered: list[str] = [] + seen: set[str] = set() + for row in rows: + if row.get("role") != "assistant": + continue + for tc in row.get("tool_calls") or []: + rel = _changed_entry(tc, ws, plan_paths) + if rel and rel != "plan.md" and rel not in seen: + seen.add(rel) + ordered.append(rel) + + out: list[Artifact] = [] + for path in ordered: + abs_path = os.path.join(ws, path) + try: + st = os.stat(abs_path) + size = int(st.st_size) + updated = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc) + deleted = False + except OSError: + # Written during the turn but gone now — keep it, marked deleted. + size = 0 + updated = datetime.now(tz=timezone.utc) + deleted = True + out.append( + Artifact( + id=_artifact_id(path), + session_id=sid, + path=path, + name=os.path.basename(path.rstrip("/")) or path, + kind="file", + size=size, + updated_at=updated, + deleted=deleted, + )) + return out diff --git a/webui/backend/app/backends/ms_agent/settings_store.py b/webui/backend/app/backends/ms_agent/settings_store.py new file mode 100644 index 000000000..c62246389 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/settings_store.py @@ -0,0 +1,20 @@ +"""Process-local guard for SDK settings.json read-modify-write sequences.""" +from __future__ import annotations + +import threading +from contextlib import contextmanager +from collections.abc import Iterator + +_settings_lock = threading.RLock() + + +@contextmanager +def settings_lock() -> Iterator[None]: + """Serialize settings.json mutations made through SDK manager adapters. + + The SDK managers rewrite the whole settings file. FastAPI sync routes run in + a threadpool, so two management requests can otherwise load the same old + file and save incompatible partial updates. + """ + with _settings_lock: + yield diff --git a/webui/backend/app/backends/ms_agent/sidecar.py b/webui/backend/app/backends/ms_agent/sidecar.py new file mode 100644 index 000000000..9bb980961 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/sidecar.py @@ -0,0 +1,78 @@ +"""WebUI-only field store (``/webui_meta.json``). + +Holds fields the SDK does not model so the frontend keeps working unchanged: +project description / auto-attach toggles, session preview, profile +agent_calls_user, agent-settings auto-attach masters, provider enabled / +generation params, model display/advanced params, and per-project memory items. + +Generic nested store: section -> key -> value. Read-modify-write under a lock +(management routes run in the threadpool).""" +from __future__ import annotations + +import json +import os +import threading +from pathlib import Path + +from app.backends.ms_agent.common import home + +_lock = threading.Lock() + + +def _path() -> Path: + return Path(home()) / "webui_meta.json" + + +def _load() -> dict: + p = _path() + if not p.exists(): + return {} + try: + return json.loads(p.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {} + + +def _save(data: dict) -> None: + p = _path() + p.parent.mkdir(parents=True, exist_ok=True) + tmp = p.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(tmp, p) + + +def get(section: str, key: str, default=None): + with _lock: + return _load().get(section, {}).get(key, default) + + +def section(name: str) -> dict: + with _lock: + return dict(_load().get(name, {})) + + +def put(section: str, key: str, value) -> None: + with _lock: + data = _load() + data.setdefault(section, {})[key] = value + _save(data) + + +def merge(section: str, key: str, patch: dict) -> None: + """Deep-merge a dict patch into section[key] (creating it as {}).""" + with _lock: + data = _load() + current = data.setdefault(section, {}).setdefault(key, {}) + if not isinstance(current, dict): + current = {} + current.update(patch) + data[section][key] = current + _save(data) + + +def drop(section: str, key: str) -> None: + with _lock: + data = _load() + if section in data and key in data[section]: + del data[section][key] + _save(data) diff --git a/webui/backend/app/backends/ms_agent/skill_notice.py b/webui/backend/app/backends/ms_agent/skill_notice.py new file mode 100644 index 000000000..43e8b177c --- /dev/null +++ b/webui/backend/app/backends/ms_agent/skill_notice.py @@ -0,0 +1,182 @@ +"""Skill-update notices — tail-only skill sync for a session's model context. + +The system prompt's skill list is a session-start snapshot and is never +rewritten (``skills.update_notice`` keeps the head byte-stable, so the +provider prefix cache survives skill changes). Instead, when the effective +skill surface changes — add/remove, enable/disable, description edit, or any +file change inside a skill's directory (SKILL.md, references/, scripts/, …) — +the next turn's user message is prefixed with a ```` notice +carrying the FULL current list. The static prompt section tells the model the +latest notice is authoritative. + +The per-session sidecar ``skill_surface.json`` (beside plan.json) records what +the model was last told. It is only committed AFTER the turn is actually +enqueued — a failed/intro-only turn leaves it untouched so the notice re-fires +next time (safe over-notify, never silent-drop). +""" +from __future__ import annotations + +import hashlib +import json +import logging +import os +from datetime import datetime, timezone + +from app.backends.ms_agent.config import session_dir, session_has_history + +logger = logging.getLogger("app.ms_agent.skill_notice") + +_SURFACE_FILE = "skill_surface.json" + + +# -- surface ------------------------------------------------------------------- + + +def _files_sig(skill_path: str) -> str: + """Cheap whole-directory signature: sorted (relpath, mtime_ns, size) over + every non-hidden file under the skill root — SKILL.md, references/, + scripts/, assets all included. Content is never read.""" + h = hashlib.sha256() + try: + for root, dirs, files in os.walk(skill_path): + dirs[:] = sorted(d for d in dirs if not d.startswith(".")) + for name in sorted(files): + if name.startswith("."): + continue + p = os.path.join(root, name) + try: + st = os.stat(p) + except OSError: + continue + rel = os.path.relpath(p, skill_path) + h.update(f"{rel}|{st.st_mtime_ns}|{st.st_size}\n".encode()) + except OSError: + pass + return h.hexdigest()[:16] + + +def build_surface(catalog) -> dict: + """{skill_id: {name, sig, files}} for the ENABLED skills — the exact set + the model is (to be) told about.""" + surface: dict = {} + for sid, skill in (catalog.get_enabled_skills() or {}).items(): + name = getattr(skill, "name", sid) or sid + desc = getattr(skill, "description", "") or "" + surface[sid] = { + "name": name, + "sig": hashlib.sha256( + f"{name}\x1f{desc}".encode()).hexdigest()[:16], + "files": _files_sig(str(getattr(skill, "skill_path", "") or "")), + } + return surface + + +def _surface_path(project, session) -> str: + return os.path.join(session_dir(project, session), _SURFACE_FILE) + + +def _load_surface(path: str) -> dict | None: + """The persisted surface, or None when this session has never been told + one (missing/corrupt file).""" + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + skills = data.get("skills") + return skills if isinstance(skills, dict) else None + except (OSError, json.JSONDecodeError): + return None + + +def _save_surface(path: str, surface: dict) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = path + ".tmp" + payload = { + "skills": surface, + "updated_at": datetime.now(timezone.utc).isoformat(), + } + with open(tmp, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=1) + os.replace(tmp, path) + + +# -- notice -------------------------------------------------------------------- + + +def _render_notice(catalog, old: dict | None, new: dict) -> str: + summary = "" + try: + summary = catalog.get_skills_summary() or "" + except Exception: + pass + if not summary: + summary = "(no skills are currently available)" + + lines: list[str] = [""] + if old is None: + # First sync of a session that predates the sidecar: the head list + # may be stale and the drift is unknowable — announce the full truth. + lines.append( + "Skill inventory may have changed since this session started. " + "CURRENT full list (authoritative; supersedes the system " + "prompt's list and any earlier notice):") + else: + lines.append( + "Skill inventory updated. CURRENT full list (supersedes the " + "system prompt's list and any earlier notice):") + lines.append(summary) + + if old is not None: + added = sorted(sid for sid in new if sid not in old) + removed = sorted(old[sid].get("name", sid) + for sid in old if sid not in new) + updated = sorted(new[sid].get("name", sid) + for sid in new if sid in old and new[sid] != old[sid]) + if added: + names = ", ".join(new[sid].get("name", sid) for sid in added) + lines.append(f"Newly added since last known state: {names}") + if removed: + lines.append( + "Removed or disabled since last known state: " + + ", ".join(removed)) + if updated: + lines.append( + "Content updated since last known state: " + + ", ".join(updated) + + " — any previously loaded copy (including files under the " + "skill's directory such as references/ or scripts/) is " + "stale; re-read it via skill_view / re-open the files " + "before relying on it.") + lines.append("Do not mention this notice to the user.") + lines.append("") + return "\n".join(lines) + + +def pending_notice(catalog, project, session): + """Compare the current skill surface with what this session was last told. + + Returns ``(notice_text | None, commit)``. ``commit()`` persists the new + surface and MUST be called only after the turn carrying the notice was + actually enqueued (or immediately for the silent brand-new-session init, + where notice_text is None). + """ + path = _surface_path(project, session) + old = _load_surface(path) + new = build_surface(catalog) + + def commit() -> None: + try: + _save_surface(path, new) + except OSError: + logger.warning("skill surface save failed", exc_info=True) + + if old is None: + if not session_has_history(project, session): + # Brand-new session: the head is built from the current catalog + # this very turn — nothing to announce, just start tracking. + commit() + return None, lambda: None + return _render_notice(catalog, None, new), commit + + if old == new: + return None, lambda: None + return _render_notice(catalog, old, new), commit diff --git a/webui/backend/app/backends/ms_agent/skills.py b/webui/backend/app/backends/ms_agent/skills.py new file mode 100644 index 000000000..bbc4c5a61 --- /dev/null +++ b/webui/backend/app/backends/ms_agent/skills.py @@ -0,0 +1,477 @@ +"""Skills adapter. + +Two kinds of skills are surfaced: + * **webui-local** — created in the UI with content; the SDK has no content-skill + model, so these live in the sidecar with full CRUD. + * **source-discovered** — skills found in the **local** dir sources reported by + the SDK's SkillsConfigManager: the per-scope **live tree** (``/skills`` + globally, ``/.ms_agent/skills`` per project — implicit, presence = + registered) plus the explicit local sources in skills.json (remote + modelscope/git sources are skipped here to avoid network in a management + call). Their id encodes the scope + skill_id (prefix ``src::``). + +UI-created skills (bundle imports) are **materialized into the scope's live +tree** — no skills.json entry needed; existence is the filesystem. Explicit +sources remain the path for referencing directories outside the trees. +Enable/disable writes the skill_id to skills.json's ``disabled`` list (state is +file-persisted even though existence isn't); a live session picks changes up at +the next turn via the chat turn-boundary sync. Deleting a tree-resident skill +removes its directory; skills from external sources are delete-protected.""" +from __future__ import annotations + +import base64 +import json +import os +import re +import uuid +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath + +from app.backends.errors import BadRequest, NotFound +from app.backends.ms_agent import sidecar +from app.backends.ms_agent.common import home, pm +from app.schemas.skill import ( + Skill, + SkillCreate, + SkillFile, + SkillFileContent, + SkillUpdate, +) + +_SRC = "src::" +_WEBUI_BUNDLE = "webui.skill.bundle.v1" + + +def _slug(value: str) -> str: + slug = re.sub(r"[^a-zA-Z0-9._-]+", "-", value.strip()).strip(".-").lower() + return slug[:64] or "skill" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _valid_scope(scope: str) -> None: + if scope == "global": + return + if scope.startswith("project:"): + if pm().get(scope.split(":", 1)[1]) is None: + raise BadRequest(f"unknown project: {scope.split(':', 1)[1]}") + return + raise BadRequest(f"invalid scope: {scope!r}") + + +def _bridge_enabled(name: str, enabled: bool, scope: str) -> None: + """Reflect enable/disable into skills.json (honored by the runtime).""" + try: + from ms_agent.config.skills_manager import SkillsConfigManager + + sk = SkillsConfigManager(global_dir=home()) + if scope == "global": + sk.set_skill_enabled(name, enabled, scope="global") + else: + proj = pm().get(scope.split(":", 1)[1]) + if proj is not None: + sk.set_skill_enabled(name, enabled, scope="project", project_path=proj.path) + except Exception: + pass + + +# -- source-discovered skills (local dirs only) -------------------------------- + + +def _enc_src(scope: str, skill_id: str) -> str: + raw = base64.urlsafe_b64encode(f"{scope}\x1f{skill_id}".encode()).decode().rstrip("=") + return _SRC + raw + + +def _dec_src(sid: str) -> tuple[str, str]: + try: + raw = sid[len(_SRC):] + pad = "=" * (-len(raw) % 4) + scope, skill_id = base64.urlsafe_b64decode(raw + pad).decode().split("\x1f", 1) + return scope, skill_id + except Exception: + raise NotFound("skill not found") + + +def _discovered_for_scope(scope: str, project_path: str | None) -> list[tuple]: + """(runtime_skill_id, SkillSchema, enabled) for local sources in the scope.""" + from ms_agent.config.skills_manager import SkillsConfigManager + from ms_agent.skill.loader import SkillLoader + from ms_agent.skill.sources import SkillSourceType, parse_skill_source + + sk = SkillsConfigManager(global_dir=home()) + if scope == "global": + sources = sk.list_sources(scope="global") + disabled = set(sk.load_global().get("disabled", [])) + else: + sources = sk.list_sources(scope="project", project_path=project_path) + disabled = set(sk.load_merged(project_path).get("disabled", [])) + + loader = SkillLoader() + out: list[tuple] = [] + for src_str in sources: + try: + src = parse_skill_source(str(src_str)) + if src.type != SkillSourceType.LOCAL_DIR or not src.path: + continue # skip remote sources — no network in a management call + for loaded_key, skill in (loader.load_skills(src.path) or {}).items(): + runtime_id = getattr(skill, "skill_id", None) or str(loaded_key).split("@", 1)[0] + enabled = runtime_id not in disabled and loaded_key not in disabled + out.append((runtime_id, skill, enabled)) + except Exception: + continue + return out + + +def _discovered_to_schema(scope: str, skill_id: str, skill, enabled: bool) -> Skill: + return Skill( + id=_enc_src(scope, skill_id), + name=getattr(skill, "name", skill_id) or skill_id, + kind=(getattr(skill, "tags", None) or ["skill"])[0], + content=getattr(skill, "description", "") or "", + enabled=enabled, + scope=scope, + created_at=datetime.now(timezone.utc), + ) + + +def _scopes_to_scan(scope: str | None) -> list[tuple[str, str | None]]: + if scope == "global": + return [("global", None)] + if scope and scope.startswith("project:"): + proj = pm().get(scope.split(":", 1)[1]) + return [(scope, proj.path)] if proj is not None else [] + # all scopes + out: list[tuple[str, str | None]] = [("global", None)] + for proj in pm().list(): + out.append((f"project:{proj.id}", proj.path)) + return out + + +# -- endpoints ----------------------------------------------------------------- + + +def list_skills(scope: str | None = None) -> list[Skill]: + rows = [ + Skill.model_validate(r) + for r in sidecar.section("skills").values() + if scope is None or r.get("scope") == scope + ] + seen = {(r.name, r.scope) for r in rows} + for sc, pp in _scopes_to_scan(scope): + for skill_id, skill, enabled in _discovered_for_scope(sc, pp): + item = _discovered_to_schema(sc, skill_id, skill, enabled) + if (item.name, item.scope) in seen: + continue # a webui-local skill of the same name/scope wins + seen.add((item.name, item.scope)) + rows.append(item) + rows.sort(key=lambda r: (r.scope, r.name)) + return rows + + +def _add_local_source(scope: str, path: str) -> Skill: + """Register a local directory as a skill source so chat actually loads its + skills (SkillsConfigManager -> skills.json -> merge_skills_into_config).""" + from ms_agent.config.skills_manager import SkillsConfigManager + + sk = SkillsConfigManager(global_dir=home()) + if scope == "global": + sk.add_source(path, scope="global") + else: + sk.add_source(path, scope="project", project_path=_project_path(scope)) + # Surface a real discovered skill from the newly added source. Other skills + # from the same source appear on refresh/list. + from ms_agent.skill.loader import SkillLoader + + for loaded_key, skill in (SkillLoader().load_skills(path) or {}).items(): + runtime_id = getattr(skill, "skill_id", None) or str(loaded_key).split("@", 1)[0] + return _discovered_to_schema(scope, runtime_id, skill, True) + # nothing discovered yet — return a source marker + return Skill( + id=_enc_src(scope, path), name=os.path.basename(path.rstrip("/")) or path, + kind="source", content=path, enabled=True, scope=scope, + created_at=datetime.now(timezone.utc), + ) + + +def _safe_relpath(path: str) -> Path: + rel = PurePosixPath(path.replace("\\", "/")) + if rel.is_absolute() or ".." in rel.parts or not rel.parts: + raise BadRequest(f"invalid skill file path: {path!r}") + return Path(*rel.parts) + + +def _live_tree(scope: str, *, create: bool = False) -> Path: + """The scope's live skills tree — presence there IS registration. + + Global: ``/skills``. Project: ``/.ms_agent/skills`` (reads + honor the legacy ``.ms-agent`` spelling via the SDK helper; writes always + use the new one).""" + if scope == "global": + root = Path(home()).expanduser() / "skills" + else: + from ms_agent.config.skills_manager import SkillsConfigManager + + proj = pm().get(scope.split(":", 1)[1]) + if proj is None: + raise BadRequest(f"unknown project: {scope.split(':', 1)[1]}") + root = SkillsConfigManager.project_skills_tree(proj.path) + if create: + root.mkdir(parents=True, exist_ok=True) + return root + + +def _unique_skill_dir(name: str, root: Path) -> Path: + base = _slug(name) + candidate = root / base + if not candidate.exists(): + return candidate + for idx in range(2, 1000): + candidate = root / f"{base}-{idx}" + if not candidate.exists(): + return candidate + return root / f"{base}-{uuid.uuid4().hex[:8]}" + + +def _bundle_files_from_content(content: str) -> list[dict]: + try: + payload = json.loads(content) + except json.JSONDecodeError as exc: + raise BadRequest("invalid skill bundle payload") from exc + if not isinstance(payload, dict) or payload.get("format") != _WEBUI_BUNDLE: + raise BadRequest("invalid skill bundle payload") + files = payload.get("files") + if not isinstance(files, list) or not files: + raise BadRequest("skill bundle must contain files") + return files + + +def _materialize_bundle(body: SkillCreate) -> Skill: + from ms_agent.skill.schema import SkillSchemaParser + + files = _bundle_files_from_content(body.content or "") + skill_md = next( + ( + f + for f in files + if isinstance(f, dict) + and str(f.get("path", "")).replace("\\", "/").split("/")[-1] == "SKILL.md" + ), + None, + ) + if not skill_md: + raise BadRequest("skill bundle must include SKILL.md") + + frontmatter = SkillSchemaParser.parse_yaml_frontmatter(str(skill_md.get("content", ""))) + if not frontmatter or not frontmatter.get("name") or not frontmatter.get("description"): + raise BadRequest("SKILL.md must include name and description frontmatter") + bundle_root = _safe_relpath(str(skill_md.get("path", ""))).parent + + skill_dir = _unique_skill_dir( + str(frontmatter.get("name") or body.name), + root=_live_tree(body.scope, create=True), + ) + skill_dir.mkdir(parents=True, exist_ok=False) + try: + for entry in files: + if not isinstance(entry, dict): + raise BadRequest("invalid skill bundle file") + rel = _safe_relpath(str(entry.get("path", ""))) + if str(bundle_root) != ".": + try: + rel = rel.relative_to(bundle_root) + except ValueError: + continue + content = entry.get("content") + if not isinstance(content, str): + raise BadRequest(f"invalid content for skill file: {rel}") + target = skill_dir / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + + # Materialized inside the live tree — presence IS registration, no + # skills.json entry. Surface the discovered skill directly. + from ms_agent.skill.loader import SkillLoader + + for loaded_key, skill in (SkillLoader().load_skills(str(skill_dir)) or {}).items(): + runtime_id = getattr(skill, "skill_id", None) or str(loaded_key).split("@", 1)[0] + return _discovered_to_schema(body.scope, runtime_id, skill, True) + raise BadRequest("skill bundle did not produce a loadable skill") + except Exception: + # Leave no half-imported skill behind when validation/write/load fails. + import shutil + + shutil.rmtree(skill_dir, ignore_errors=True) + raise + + +def create_skill(body: SkillCreate) -> Skill: + _valid_scope(body.scope) + if body.kind == "bundle": + return _materialize_bundle(body) + + # If `content` is an existing local directory, treat it as a skill SOURCE + # (so chat loads it) rather than a content-only sidecar skill. A directory + # already inside the scope's live tree is registered by presence — don't + # add a redundant skills.json entry, just surface what the scan sees. + candidate = os.path.expanduser((body.content or "").strip()) + if candidate and os.path.isdir(candidate): + if _in_live_tree(body.scope, Path(candidate)): + from ms_agent.skill.loader import SkillLoader + + for loaded_key, skill in (SkillLoader().load_skills(candidate) or {}).items(): + runtime_id = getattr(skill, "skill_id", None) or str(loaded_key).split("@", 1)[0] + return _discovered_to_schema(body.scope, runtime_id, skill, True) + raise BadRequest("no loadable skill found in directory") + return _add_local_source(body.scope, candidate) + if body.kind == "source": + raise BadRequest("local skill source must be an existing directory") + + sid = "sk-" + uuid.uuid4().hex[:12] + row = { + "id": sid, "name": body.name, "kind": body.kind, "content": body.content, + "enabled": body.enabled, "scope": body.scope, "created_at": _now(), + } + sidecar.put("skills", sid, row) + _bridge_enabled(body.name, body.enabled, body.scope) + return Skill.model_validate(row) + + +def get_skill(sid: str) -> Skill: + if sid.startswith(_SRC): + scope, skill_id = _dec_src(sid) + pp = None if scope == "global" else _project_path(scope) + for s, skill, enabled in _discovered_for_scope(scope, pp): + if s == skill_id: + return _discovered_to_schema(scope, skill_id, skill, enabled) + raise NotFound("skill not found") + row = sidecar.get("skills", sid) + if not row: + raise NotFound("skill not found") + return Skill.model_validate(row) + + +def _skill_dir_for(sid: str) -> Path | None: + """Resolve the on-disk directory of a discovered (``src::``) skill; None + for sidecar-only skills (single markdown body, no directory).""" + if not sid.startswith(_SRC): + return None + scope, skill_id = _dec_src(sid) + pp = None if scope == "global" else _project_path(scope) + for s, skill, _enabled in _discovered_for_scope(scope, pp): + if s != skill_id: + continue + p = Path(str(getattr(skill, "skill_path", "") or "")) + if p.is_file(): # some loaders point at SKILL.md itself + p = p.parent + return p if p.is_dir() else None + raise NotFound("skill not found") + + +_SKIP_TREE_PARTS = {".git", "__pycache__", ".DS_Store", "node_modules"} + + +def list_skill_files(sid: str) -> list[SkillFile]: + """REAL relative file listing of the skill's directory (SKILL.md first). + Sidecar-only skills expose just their markdown body as SKILL.md.""" + root = _skill_dir_for(sid) + if root is None: + get_skill(sid) # 404 for unknown ids + return [SkillFile(path="SKILL.md")] + out: list[SkillFile] = [] + for p in sorted(root.rglob("*")): + if not p.is_file(): + continue + rel = p.relative_to(root) + if any(part in _SKIP_TREE_PARTS or part.startswith(".") + for part in rel.parts): + continue + try: + size = p.stat().st_size + except OSError: + size = None + out.append(SkillFile(path=rel.as_posix(), size=size)) + # SKILL.md first, then alphabetical — mirrors what the viewer opens first. + out.sort(key=lambda f: (f.path != "SKILL.md", f.path)) + return out + + +def read_skill_file(sid: str, path: str) -> SkillFileContent: + """UTF-8 content of one file inside the skill directory (path-traversal + safe). ``content=None`` flags a binary file.""" + rel = _safe_relpath(path) + root = _skill_dir_for(sid) + if root is None: + sk = get_skill(sid) + if rel.as_posix() == "SKILL.md": + return SkillFileContent(path="SKILL.md", content=sk.content) + raise NotFound("file not found") + f = root / rel + if not f.is_file(): + raise NotFound("file not found") + try: + return SkillFileContent(path=rel.as_posix(), + content=f.read_text("utf-8")) + except (UnicodeDecodeError, ValueError): + return SkillFileContent(path=rel.as_posix(), content=None) + + +def update_skill(sid: str, body: SkillUpdate) -> Skill: + if sid.startswith(_SRC): + scope, skill_id = _dec_src(sid) + if body.enabled is not None: + _bridge_enabled(skill_id, body.enabled, scope) # only enable/disable + return get_skill(sid) + row = sidecar.get("skills", sid) + if not row: + raise NotFound("skill not found") + for field in ("name", "kind", "content", "enabled"): + value = getattr(body, field) + if value is not None: + row[field] = value + sidecar.put("skills", sid, row) + if body.enabled is not None: + _bridge_enabled(row["name"], row["enabled"], row["scope"]) + return Skill.model_validate(row) + + +def delete_skill(sid: str) -> None: + if sid.startswith(_SRC): + scope, skill_id = _dec_src(sid) + pp = None if scope == "global" else _project_path(scope) + for s, skill, _enabled in _discovered_for_scope(scope, pp): + if s != skill_id: + continue + skill_path = Path(getattr(skill, "skill_path", "") or "") + if skill_path and _in_live_tree(scope, skill_path): + # Tree-resident: presence is registration, so deletion is + # removing the directory (filesystem = existence truth). + import shutil + + shutil.rmtree(skill_path, ignore_errors=True) + return + break + raise BadRequest( + "skill outside the managed skills tree cannot be deleted; " + "disable it or remove its source" + ) + if not sidecar.get("skills", sid): + raise NotFound("skill not found") + sidecar.drop("skills", sid) + + +def _in_live_tree(scope: str, path: Path) -> bool: + """True when *path* resolves inside the scope's live tree (rmtree guard — + relative_to avoids startswith prefix bypasses).""" + try: + path.resolve().relative_to(_live_tree(scope).resolve()) + return True + except (ValueError, OSError): + return False + + +def _project_path(scope: str) -> str | None: + proj = pm().get(scope.split(":", 1)[1]) + return proj.path if proj is not None else None diff --git a/webui/backend/app/backends/ms_agent/titler.py b/webui/backend/app/backends/ms_agent/titler.py new file mode 100644 index 000000000..24298a0de --- /dev/null +++ b/webui/backend/app/backends/ms_agent/titler.py @@ -0,0 +1,178 @@ +"""Agent-side session titling + topic classification. + +On a session's first user message the chat stream asks the LLM to summarize the +message into a short title and pick one topic category (see ``CATEGORIES``). +Both are cheap (one small completion) and best-effort: any failure returns None +so the caller keeps the cheap first-line fallback title and an empty category. + +Credentials/model come from the SDK's seeded ``/settings.json`` ``llm`` +block, falling back to the exported OPENAI_* env. Uses the OpenAI-compatible +``/chat/completions`` endpoint directly (httpx) — no agent runtime needed. +""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +import re +from pathlib import Path + +import httpx + +from app.backends.ms_agent.common import home + +logger = logging.getLogger("app.ms_agent.titler") + +# Fixed topic taxonomy. Kept in sync with the frontend category→icon map +# (ProjectOverviewView). "general" is the fallback for anything uncategorized. +CATEGORIES: tuple[str, ...] = ( + "coding", + "writing", + "research", + "planning", + "data", + "creative", + "media", + "general", +) + +_SYSTEM = ( + "You name a chat and classify its topic from the user's first message. " + 'Reply with ONLY a compact JSON object: {"title": "...", "category": "..."}.\n' + "- title: a short, specific title in the SAME language as the message; no " + "quotes, no ending punctuation; at most ~6 words (or ~16 Chinese characters).\n" + "- category: exactly one of:\n" + " coding (programming, debugging, code), writing (writing or editing text/docs), " + "research (searching or browsing the web for information), planning (plans, todos, " + "scheduling, multi-step tasks), data (data analysis, spreadsheets, charts), " + "creative (brainstorming, ideas, design), media (images, audio, video, or file " + "handling), general (casual chat, Q&A, anything else).\n" + "No prose, no code fences." +) + + +def _llm_config() -> tuple[str, str, str, str]: + """(model, api_key, base_url, protocol) from settings.json llm — plus the + active provider's ``protocol`` override — then OPENAI_* env. + + ``protocol == "anthropic"`` means the active provider speaks the Anthropic + Messages API (e.g. DeepSeek's ``/anthropic`` gateway): posting to + ``/chat/completions`` there 404s, which would silently disable titling.""" + cfg: dict = {} + providers: dict = {} + try: + data = json.loads((Path(home()) / "settings.json").read_text(encoding="utf-8")) + if isinstance(data.get("llm"), dict): + cfg = data["llm"] + if isinstance(data.get("providers"), dict): + providers = data["providers"] + except (OSError, ValueError): + cfg = {} + model = cfg.get("model") or os.environ.get("MS_AGENT_LLM_MODEL") or "" + api_key = cfg.get("api_key") or os.environ.get("OPENAI_API_KEY") or "" + base_url = cfg.get("base_url") or os.environ.get("OPENAI_BASE_URL") or "" + entry = providers.get(str(cfg.get("provider") or "")) + protocol = str(entry.get("protocol") or "") if isinstance(entry, dict) else "" + return str(model), str(api_key), str(base_url), protocol.lower() + + +def _parse(content: str) -> tuple[str, str] | None: + """Extract (title, category) from the model's JSON reply, leniently.""" + if not content: + return None + match = re.search(r"\{.*\}", content, re.DOTALL) + if not match: + return None + try: + obj = json.loads(match.group(0)) + except ValueError: + return None + if not isinstance(obj, dict): + return None + title = str(obj.get("title") or "").strip().strip("\"'").strip() + title = title.splitlines()[0][:60] if title else "" + category = str(obj.get("category") or "").strip().lower() + if category not in CATEGORIES: + category = "general" + if not title: + return None + return title, category + + +def _anthropic_text(data: dict) -> str: + """The first text block of an Anthropic Messages response (a thinking-mode + gateway may put a thinking block before it).""" + for block in data.get("content") or []: + if isinstance(block, dict) and block.get("type") == "text": + return str(block.get("text") or "") + return "" + + +async def generate_title_and_category(text: str) -> tuple[str, str] | None: + """Summarize the first user message into (title, category), or None on any + failure (missing creds/model, network error, unparseable reply). Speaks the + active provider's wire protocol: Anthropic Messages when its ``protocol`` + override says so, OpenAI-compatible chat/completions otherwise.""" + text = (text or "").strip() + if not text: + return None + model, api_key, base_url, protocol = _llm_config() + if not (model and api_key and base_url): + return None + if protocol == "anthropic": + url = base_url.rstrip("/") + "/v1/messages" + headers = {"x-api-key": api_key, "anthropic-version": "2023-06-01"} + payload = { + "model": model, + "system": _SYSTEM, + "messages": [{"role": "user", "content": text[:2000]}], + "temperature": 0.2, + "max_tokens": 600, + # Thinking-default gateways (DeepSeek /anthropic) otherwise spend + # the whole budget on a thinking block for a long first message and + # return no text block at all — the observed intermittent-title + # failure. Explicitly off; standard Anthropic accepts this too. + "thinking": {"type": "disabled"}, + } + else: + url = base_url.rstrip("/") + "/chat/completions" + headers = {"Authorization": f"Bearer {api_key}"} + payload = { + "model": model, + "messages": [ + {"role": "system", "content": _SYSTEM}, + {"role": "user", "content": text[:2000]}, + ], + "temperature": 0.2, + "max_tokens": 160, + # Qwen thinking models require thinking off for non-streaming calls; + # OpenAI-compatible servers ignore the extra field. + "enable_thinking": False, + } + # One retry after a beat: transient gateway hiccups were observed live. + # Credentials/config problems returned above never reach this loop, so + # the retry only spends time when a real request was attempted. + for attempt in (1, 2): + try: + async with httpx.AsyncClient(timeout=20) as client: + resp = await client.post(url, json=payload, headers=headers) + resp.raise_for_status() + data = resp.json() + if protocol == "anthropic": + content = _anthropic_text(data) + else: + content = data["choices"][0]["message"]["content"] + except (httpx.HTTPError, KeyError, ValueError, IndexError) as exc: + logger.warning("titler request failed (attempt %d, %s %s): %s", + attempt, protocol, model, exc) + content = "" + if content: + parsed = _parse(str(content)) + if parsed is not None: + return parsed + logger.warning("titler reply unparseable (attempt %d, %s %s): %r", + attempt, protocol, model, str(content)[:120]) + if attempt == 1: + await asyncio.sleep(2) + return None diff --git a/webui/backend/app/backends/ms_agent/workspace.py b/webui/backend/app/backends/ms_agent/workspace.py new file mode 100644 index 000000000..86bf61e9e --- /dev/null +++ b/webui/backend/app/backends/ms_agent/workspace.py @@ -0,0 +1,321 @@ +"""Workspace adapter — SDK Workspace(project.path) with a flat recursive listing. + +The frontend renders a tree from a flat list of relative paths, so we walk the +project dir (== output_dir) recursively. Framework internals are hidden, but +``.ms_agent`` itself is SHOWN so users can see and manage the project-scoped +state it holds (e.g. finer-grained permission files) — only its pure-machinery +subtrees (the ``snapshots`` git store, transient ``locks``) stay hidden, along +with the top-level ``sessions`` dir (session logs).""" +from __future__ import annotations + +import shutil +import time +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath + +from app.backends.errors import BadRequest, Conflict, NotFound +from app.backends.ms_agent.common import pm +from app.core.filetypes import guess_type, is_binary_ext +from app.schemas.workspace import WorkspaceFile, WorkspaceFileCreate, WorkspaceFileUpdate + +_HIDDEN_TOP = {"sessions"} + +# Dot-directories that are framework internals and should never appear in the +# workspace listing. User-facing dot-dirs (like .github) are kept visible. +# NOTE: ``.ms_agent`` is intentionally NOT here — the project-scoped state it +# holds (user memory, and future finer-grained permission files) must be visible +# and hand-manageable, else such files could only ever be auto-added, never +# deleted. Its pure-machinery subtrees (``_HIDDEN_MSA_SUBDIRS``) and its +# machine-format memory dumps (``_HIDDEN_MSA_MEMORY_SUFFIXES``) are hidden +# separately. +# The bare-root ``.locks/.ms_agent_artifacts/.index/.temp`` are the SDK's LEGACY +# spots (see ms_agent/project/paths.py — all relocated under .ms_agent/) — kept +# here as a safety net for workspaces written by older SDKs / tools that still +# litter the workspace root. +_HIDDEN_DOT = { + ".git", ".ms_agent_webui", ".venv", "__pycache__", + ".locks", ".ms_agent_artifacts", ".index", ".temp", +} + +# Subtrees directly under the (now visible) ``.ms_agent`` dir that are pure +# machinery and don't belong in the raw file tree at all: +# - ``snapshots``: the git object store for workspace diffing (per-file +# browsing/deletion would corrupt the snapshot history); +# - ``locks``: transient file locks (deleting a live lock disrupts writes). +# (``memory`` is NOT here — it's shown, but its machine-format dumps are hidden +# by suffix; see ``_HIDDEN_MSA_MEMORY_SUFFIXES``.) +_HIDDEN_MSA_SUBDIRS = {"snapshots", "locks"} + +# ``.ms_agent/memory/`` holds BOTH the user's memory (visible, hand-editable, +# reloaded by the SDK in-project — ``MEMORY.md``, mem0 store subdirs) AND the +# SDK's machine-format state dumps. The dumps are ``.yaml`` + ``.json`` +# PAIRS written by utils.save_history for the main agent (``Agent-default``) and +# for EVERY agent-tool sub-agent (``worker-``, … — the tag/prefix is +# arbitrary and grows as more agent tools run). They serialize message history + +# the resolved agent config (which INCLUDES secrets like API keys) and are +# rewritten each turn — SDK state, not user content. So keep memory/ visible but +# hide any ``.yaml``/``.json`` under it (matches every tag, current and future), +# leaving ``MEMORY.md`` and other human-readable memory files in view. +_HIDDEN_MSA_MEMORY_SUFFIXES = {".yaml", ".yml", ".json"} + +# Upper bound on inline file content returned by GET (the editor preview only +# needs a readable slice; larger files stay listable but preview-truncated). +_MAX_PREVIEW_BYTES = 512 * 1024 + + +def _project_path(pid: str) -> str: + proj = pm().get(pid) + if proj is None: + raise NotFound("project not found") + return proj.path + + +def _ws(pid: str): + from ms_agent.project import Workspace + + return Workspace(_project_path(pid)) + + +def _safe(ws, rel: str) -> Path: + target = (ws.root / rel).resolve() + try: + target.relative_to(ws.root) + except ValueError: + raise BadRequest("path traversal blocked") + return target + + +def _hidden(rel: Path) -> bool: + parts = rel.parts + if parts and parts[0] in _HIDDEN_TOP: + return True + # Only hide specific internal dot-dirs, not all dot-prefixed paths + # (user-facing dirs like .github, .vscode, .env files are kept visible). + if any(p in _HIDDEN_DOT for p in parts): + return True + # Under .ms_agent: hide the pure-machinery subtrees (snapshots/locks) whole, + # and — inside the otherwise-visible memory/ — the SDK's .yaml/.json + # state dumps (any tag), keeping user memory (MEMORY.md, …) in view. + for i in range(len(parts) - 1): + if parts[i] != ".ms_agent": + continue + if parts[i + 1] in _HIDDEN_MSA_SUBDIRS: + return True + if (parts[i + 1] == "memory" + and rel.suffix.lower() in _HIDDEN_MSA_MEMORY_SUFFIXES): + return True + return False + + +def _entry(pid: str, root: Path, target: Path) -> WorkspaceFile: + stat = target.stat() + is_dir = target.is_dir() + return WorkspaceFile( + project_id=pid, + path=str(target.relative_to(root)), + kind="folder" if is_dir else "file", + # A directory's own inode size is meaningless to a user, so it reports + # the RECURSIVE total of the files it contains (see `_fill_dir_sizes` / + # `_dir_size`); the raw stat size is only used for files. + size=0 if is_dir else stat.st_size, + updated_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc), + preview=None, + content_type=None if is_dir else guess_type(target.name), + ) + + +def _fill_dir_sizes(entries: list[WorkspaceFile]) -> None: + """Roll each file's size up into every ancestor folder entry, in place. + + Single pass over the already-collected listing (no extra disk walk): a + folder ends up reporting the total bytes of its whole subtree instead of a + bare 0, which reads as "empty" next to its non-empty children. + """ + folders = {e.path: e for e in entries if e.kind == "folder"} + if not folders: + return + for e in entries: + if e.kind == "folder": + continue + parent = PurePosixPath(e.path.replace("\\", "/")).parent + while str(parent) not in (".", "/", ""): + folder = folders.get(str(parent)) + if folder is not None: + folder.size += e.size + parent = parent.parent + + +def _dir_size(target: Path) -> int: + """Recursive byte total of a directory (single-entry reads).""" + total = 0 + for p in target.rglob("*"): + try: + if p.is_file(): + total += p.stat().st_size + except OSError: + continue + return total + + +def list_files(pid: str) -> list[WorkspaceFile]: + ws = _ws(pid) + root = ws.root + out: list[WorkspaceFile] = [] + for target in root.rglob("*"): + rel = target.relative_to(root) + if _hidden(rel): + continue + out.append(_entry(pid, root, target)) + _fill_dir_sizes(out) + out.sort(key=lambda f: f.path) + return out + + +def create_file(pid: str, body: WorkspaceFileCreate) -> WorkspaceFile: + ws = _ws(pid) + target = _safe(ws, body.path) + if target.exists(): + raise Conflict("file already exists") + if body.kind == "folder": + target.mkdir(parents=True, exist_ok=True) + else: + ws.write_file(body.path, body.content) + return _entry(pid, ws.root, target) + + +def get_file(pid: str, path: str) -> WorkspaceFile: + ws = _ws(pid) + target = _safe(ws, path) + # A path hidden from the listing must not be readable by direct path either + # (else e.g. an .ms_agent/memory dump — which embeds API keys — would still + # leak via a guessed URL). 404 so hidden files' existence isn't revealed. + if _hidden(Path(path)): + raise NotFound("file not found") + if not target.exists(): + raise NotFound("file not found") + entry = _entry(pid, ws.root, target) + if target.is_dir(): + # Match the listing: a folder reports its subtree's total bytes. + entry.size = _dir_size(target) + return entry + if target.is_file() and not is_binary_ext(path): + # Known-binary extensions (archives, media, executables, …) are never + # inlined as text — not even when their bytes happen to decode (e.g. an + # archive an older buggy upload corrupted into a lossy text blob). The + # frontend renders/downloads them via .../raw instead. + try: + text = ws.read_file(path) + entry.preview = text[:200] + # Cap the inline content so a huge file can't bloat the response; + # the editor preview only needs a readable slice. + entry.content = text[:_MAX_PREVIEW_BYTES] + except (UnicodeDecodeError, OSError): + entry.preview = None + entry.content = None + return entry + + +def update_file(pid: str, path: str, body: WorkspaceFileUpdate) -> WorkspaceFile: + ws = _ws(pid) + target = _safe(ws, path) + if not target.is_file(): + raise NotFound("file not found") + ws.write_file(path, body.content) + return _entry(pid, ws.root, target) + + +def delete_file(pid: str, path: str) -> None: + ws = _ws(pid) + target = _safe(ws, path) + if not target.exists(): + raise NotFound("file not found") + ws.delete(path) + + +def move_file(pid: str, src: str, dst: str) -> WorkspaceFile: + """Rename/move ``src`` to ``dst`` (both workspace-relative). Works for files + and folders (children move along). Refuses to clobber an existing target or + to move a folder into its own subtree.""" + ws = _ws(pid) + s = _safe(ws, src) + d = _safe(ws, dst) + if not s.exists(): + raise NotFound("file not found") + if d == s: + return _entry(pid, ws.root, s) + if d.exists(): + raise Conflict("target already exists") + if s.is_dir(): + # Block moving a folder into itself or a descendant (would recurse). + try: + d.relative_to(s) + raise BadRequest("cannot move a folder into itself") + except ValueError: + pass + d.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(s), str(d)) + return _entry(pid, ws.root, d) + + +def _dedup_target(base: Path, data: bytes) -> Path: + """Non-clobbering destination for a chat upload under ``user_files/``. + + The FIRST upload of a given browser file name keeps that name as-is. A + LATER upload of the same name is compared byte-for-byte against that first + file: identical bytes (re-upload of the same file) REUSE it (no new file); + different bytes are stored timestamped (``-``) so they + never overwrite the first. A same-millisecond collision falls back to a + counter. + """ + if not base.exists(): + return base + try: + if base.read_bytes() == data: + return base # identical re-upload of the first file → reuse + except OSError: + pass + ts = int(time.time() * 1000) + stem, suffix, parent = base.stem, base.suffix, base.parent + cand = parent / f"{stem}-{ts}{suffix}" + i = 1 + while cand.exists(): + cand = parent / f"{stem}-{ts}-{i}{suffix}" + i += 1 + return cand + + +def save_upload(pid: str, rel: str, data: bytes, dedup: bool = False) -> WorkspaceFile: + """Persist a raw uploaded file (binary-safe) under the workspace. + + Uploads write bytes directly instead of going through the text-only + ``write_file`` so images/archives/etc. aren't corrupted by UTF-8 coercion. + By default a same-path file is overwritten (explicit-path uploads / import). + With ``dedup`` (chat attachments landing flat in ``user_files/``) the first + upload of a name keeps it; a later same-named upload reuses that first file + when the bytes are identical, else is timestamped + (``-``), so the returned ``path`` is the real location + the caller must use for links + agent references. + """ + ws = _ws(pid) + if not rel: + raise BadRequest("missing file path") + base = _safe(ws, rel) + if base.is_dir(): + raise Conflict("a folder already exists at this path") + target = _dedup_target(base, data) if dedup else base + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + return _entry(pid, ws.root, target) + + +def raw_file(pid: str, path: str) -> tuple[Path, str]: + """Resolve an existing file to (path, mime) for raw byte serving.""" + ws = _ws(pid) + target = _safe(ws, path) + # Same guard as get_file: never serve the bytes of a listing-hidden file + # (e.g. an .ms_agent/memory state dump with embedded secrets) by direct path. + if _hidden(Path(path)): + raise NotFound("file not found") + if not target.is_file(): + raise NotFound("file not found") + return target, guess_type(target.name) or "application/octet-stream" diff --git a/webui/backend/app/core/__init__.py b/webui/backend/app/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/webui/backend/app/core/envelope.py b/webui/backend/app/core/envelope.py new file mode 100644 index 000000000..61f687d60 --- /dev/null +++ b/webui/backend/app/core/envelope.py @@ -0,0 +1,107 @@ +"""Uniform API response envelope for all non-chat (RESTful CRUD) endpoints. + +Every successful management response is wrapped as:: + + {"code": 0, "message": "success", "data": } + +and every error (HTTPException, validation error, or unhandled crash) as:: + + {"code": , "message": "", "data": null} + +HTTP status codes are preserved so REST semantics stay intact; the envelope +adds a stable, structured shape the frontend can rely on. The chat SSE stream +is intentionally excluded — it owns its own wire format. +""" +from __future__ import annotations + +import json +from typing import Any, Callable + +from fastapi import FastAPI, Request, Response +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from fastapi.routing import APIRoute +from starlette.exceptions import HTTPException as StarletteHTTPException + + +def _envelope(data: Any = None, *, code: int = 0, + message: str = "success") -> dict[str, Any]: + return {"code": code, "message": message, "data": data} + + +def success_response(data: Any, status_code: int = 200) -> JSONResponse: + return JSONResponse(_envelope(data), status_code=status_code) + + +def error_response(status_code: int, message: str, *, + data: Any = None) -> JSONResponse: + return JSONResponse( + _envelope(data, code=status_code, message=message), + status_code=status_code, + ) + + +class EnvelopeRoute(APIRoute): + """Wraps a route's serialized success payload into the standard envelope. + + Runs after FastAPI has already validated/serialized the return value + against `response_model`, so route signatures and validation are untouched. + Errors raised inside the endpoint bypass this and are handled by the + registered exception handlers below. + """ + + def get_route_handler(self) -> Callable: + original = super().get_route_handler() + + async def custom(request: Request) -> Response: + response = await original(request) + # Skip streaming or bodiless-by-design responses we can't buffer. + raw = getattr(response, "body", None) + if raw is None: + return response + media = response.headers.get("content-type", "") + # Only unwrap JSON payloads. A 204 delete has an empty body and no + # JSON content-type — treat it as `data: null`. + if raw and "application/json" not in media: + return response + data = json.loads(raw) if raw else None + # Preserve the RESTful status code (e.g. 201 Created). A 204 becomes + # 200 since the envelope now carries a body. + status = 200 if response.status_code == 204 else response.status_code + return success_response(data, status_code=status) + + return custom + + +async def _http_exception_handler( + request: Request, exc: StarletteHTTPException) -> JSONResponse: + detail = exc.detail + message = detail if isinstance(detail, str) else "request failed" + return error_response(exc.status_code, message, data=None) + + +async def _validation_exception_handler( + request: Request, exc: RequestValidationError) -> JSONResponse: + errors = exc.errors() + message = "validation error" + if errors: + first = errors[0] + loc = ".".join( + str(p) for p in first.get("loc", []) if p not in ("body", "query")) + msg = first.get("msg", "validation error") + message = f"{loc}: {msg}" if loc else msg + # Keep the raw error list in `data` for debugging / field-level UIs. + return error_response(422, message, data=errors) + + +async def _unhandled_exception_handler( + request: Request, exc: Exception) -> JSONResponse: + return error_response(500, "internal server error", data=None) + + +def register_exception_handlers(app: FastAPI) -> None: + """Install envelope-shaped handlers for HTTP, validation, and crash errors.""" + app.add_exception_handler(StarletteHTTPException, _http_exception_handler) + app.add_exception_handler(RequestValidationError, + _validation_exception_handler) + app.add_exception_handler(Exception, _unhandled_exception_handler) diff --git a/webui/backend/app/core/filetypes.py b/webui/backend/app/core/filetypes.py new file mode 100644 index 000000000..7306c61ff --- /dev/null +++ b/webui/backend/app/core/filetypes.py @@ -0,0 +1,70 @@ +"""Shared file-type classification for the workspace preview. + +The frontend picks a preview by MIME type + whether the file is text-decodable: +text -> Monaco, image/video/audio -> media element, else -> unsupported. Two +quirks are handled centrally here so both the ms_agent and mock backends agree: + +* ``mimetypes`` maps a few *source* extensions to non-text MIME types — most + notably ``.ts`` -> ``video/mp2t`` — which would mis-flag TypeScript as video. + ``guess_type`` overrides those so content_type stays trustworthy. +* Some extensions are *always* binary containers (archives, executables, media, + fonts, office docs). Their bytes must never be shown as text even if they + happen to decode — e.g. an archive that an older buggy upload corrupted into a + lossy text blob. ``is_binary_ext`` flags them so callers skip inline content. +""" +from __future__ import annotations + +import mimetypes +from pathlib import Path + +# Source/text extensions that ``mimetypes`` resolves to a media MIME type. +# Overridden to a text type so media detection never trips on them. +_TEXT_TYPE_OVERRIDES = { + ".ts": "text/typescript", + ".mts": "text/typescript", + ".cts": "text/typescript", +} + +# Extensions whose contents are always binary and must not be inlined as text. +# Media is included so those files are served via .../raw and rendered, never +# poured into the code editor. (`.ts` is intentionally absent — in a code +# workspace it's TypeScript, not an MPEG transport stream.) +_BINARY_EXTS = { + # archives / compression + ".zip", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".tar", ".jar", + ".war", ".whl", ".lz", ".lzma", ".cab", ".deb", ".rpm", + # executables / libraries / bytecode + ".exe", ".dll", ".so", ".dylib", ".bin", ".class", ".pyc", ".pyo", + ".wasm", ".msi", ".apk", ".dex", + # disk images + ".iso", ".dmg", ".img", + # documents (binary containers) + ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", + ".ods", ".odp", + # fonts + ".ttf", ".otf", ".woff", ".woff2", ".eot", + # databases + ".sqlite", ".db", ".mdb", + # images + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".tif", + ".tiff", ".avif", ".heic", ".svg", + # video + ".mp4", ".webm", ".mov", ".m4v", ".mkv", ".avi", ".ogv", ".mpg", + ".mpeg", ".flv", ".wmv", + # audio + ".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".wma", ".opus", + ".mid", ".midi", +} + + +def guess_type(name: str) -> str | None: + """Best-effort MIME type, with source-extension overrides applied.""" + ext = Path(name).suffix.lower() + if ext in _TEXT_TYPE_OVERRIDES: + return _TEXT_TYPE_OVERRIDES[ext] + return mimetypes.guess_type(name)[0] + + +def is_binary_ext(name: str) -> bool: + """True for extensions that must never be previewed as editable text.""" + return Path(name).suffix.lower() in _BINARY_EXTS diff --git a/webui/backend/app/core/model_discovery.py b/webui/backend/app/core/model_discovery.py new file mode 100644 index 000000000..43998d82f --- /dev/null +++ b/webui/backend/app/core/model_discovery.py @@ -0,0 +1,54 @@ +"""Discover available model ids from a provider's standard /models endpoint. + +Best-effort only: any failure (missing key, network error, non-standard +endpoint, non-2xx) degrades silently to an empty list so the UI can fall back +to free-form manual input. +""" +from __future__ import annotations + +import httpx + + +def _parse_ids(payload: object) -> list[str]: + """Extract model ids from a standard OpenAI/Anthropic /models response. + + Both protocols return `{"data": [{"id": "..."}, ...]}`. + """ + ids: list[str] = [] + if isinstance(payload, dict): + data = payload.get("data") + if isinstance(data, list): + for item in data: + if isinstance(item, dict): + mid = item.get("id") + if isinstance(mid, str) and mid: + ids.append(mid) + return sorted(set(ids)) + + +def fetch_model_ids(base_url: str, protocol: str, api_key: str) -> list[str]: + """Return available model ids for a provider, or [] on any failure.""" + if not base_url: + return [] + base = base_url.rstrip("/") + try: + if protocol == "anthropic": + if not base.endswith("/v1"): + base = f"{base}/v1" + url = f"{base}/models" + headers = {"anthropic-version": "2023-06-01"} + if api_key: + headers["x-api-key"] = api_key + else: + url = f"{base}/models" + headers = {} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + with httpx.Client(timeout=8) as client: + resp = client.get(url, headers=headers) + if resp.status_code // 100 != 2: + return [] + return _parse_ids(resp.json()) + except Exception: + return [] diff --git a/webui/backend/app/core/settings.py b/webui/backend/app/core/settings.py new file mode 100644 index 000000000..863494061 --- /dev/null +++ b/webui/backend/app/core/settings.py @@ -0,0 +1,60 @@ +from pathlib import Path +from typing import Literal + +from dotenv import load_dotenv +from pydantic_settings import BaseSettings, SettingsConfigDict + +# app/core/settings.py -> backend/ ; anchor .env to the file, not the CWD, so it +# loads identically from the server, a script, or a test regardless of cwd. +_BACKEND_DIR = Path(__file__).resolve().parents[2] + +# Publish both .env files into os.environ (never overriding real exports). +# pydantic-settings only extracts its own declared fields; MCP ${VAR} +# placeholders (headers/args/env in mcp.json) resolve against os.environ at +# connection time, so keys like DASHSCOPE_API_KEY must actually be there. +for _env_file in (_BACKEND_DIR / ".env", _BACKEND_DIR.parent / ".env"): + if _env_file.is_file(): + load_dotenv(_env_file, override=False) + + +class Settings(BaseSettings): + # Read backend/.env (backend config) and the repo-root ../.env (shared + # provider secrets). Keys don't overlap, so load order is immaterial. + model_config = SettingsConfigDict( + env_file=(str(_BACKEND_DIR / ".env"), str(_BACKEND_DIR.parent / ".env")), + env_file_encoding="utf-8", + extra="ignore", + ) + + host: str = "127.0.0.1" + port: int = 8000 + + cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173" + + # mock — in-memory seed data (frontend-only dev, no SDK needed) + # ms_agent — real ms-agent SDK (projects/sessions/config/chat on disk) + # anthropic/openai — reserved (not wired) + agent_backend: Literal["mock", "ms_agent", "anthropic", "openai"] = "mock" + + anthropic_api_key: str = "" + openai_api_key: str = "" + openai_base_url: str = "" + + # --- ms_agent backend --- + # Override the SDK global home (default ~/.ms_agent). Maps to MS_AGENT_HOME. + ms_agent_home: str = "" + # Bootstrap the SDK's settings.json `llm` block on first run when absent, so + # ConfigResolver yields a working model. Credentials reuse openai_api_key / + # openai_base_url. provider must be a known registry id (openai, modelscope, + # dashscope, anthropic, ...). + ms_agent_llm_provider: str = "openai" + ms_agent_llm_model: str = "" + # Optional third-party key passed through to the SDK env (e.g. web-search MCP). + exa_api_key: str = "" + + @property + def cors_origin_list(self) -> list[str]: + return [o.strip() for o in self.cors_origins.split(",") if o.strip()] + + +settings = Settings() diff --git a/webui/backend/app/core/store.py b/webui/backend/app/core/store.py new file mode 100644 index 000000000..cfc42646b --- /dev/null +++ b/webui/backend/app/core/store.py @@ -0,0 +1,508 @@ +"""In-memory mock store for the prototype. + +Entities live in module-level dicts keyed by id. A `seed()` call at app boot +populates demo data so the UI has something to render before any user action. +There is no persistence — restart the process and the store resets. +""" + +from datetime import datetime, timedelta, timezone +from uuid import uuid4 + +DEFAULT_PROJECT_ID = "default" + + +def now() -> datetime: + return datetime.now(timezone.utc) + + +def new_id(prefix: str = "") -> str: + return f"{prefix}{uuid4().hex[:12]}" + + +# Per-entity stores -------------------------------------------------------- + +projects: dict[str, dict] = {} +sessions: dict[str, dict] = {} +artifacts: dict[str, list[dict]] = {} # session_id -> artifacts +mcps: dict[str, dict] = {} +skills: dict[str, dict] = {} +# project_id -> list of MemoryFile dicts. Global memory is NOT supported — +# the spec gates memory at project level. +memory_files: dict[str, list[dict]] = {} +# scope ('global' | 'project:') -> single Instruction blob. +instructions: dict[str, dict] = {} +providers: dict[str, dict] = {} +models: dict[str, dict] = {} +agent_settings: dict = {} # singleton +profile: dict = {} # singleton (user profile / user.md) +# project_id -> list of WorkspaceFile dicts. +workspace_files: dict[str, list[dict]] = {} + + +# Scope helpers ----------------------------------------------------------- + + +def scope_key(scope: str) -> str: + """Normalize scope. Accepts 'global' or 'project:'.""" + if scope == "global": + return "global" + if scope.startswith("project:"): + pid = scope.split(":", 1)[1] + if pid not in projects: + raise ValueError(f"unknown project: {pid}") + return f"project:{pid}" + raise ValueError(f"invalid scope: {scope!r}") + + +# Seed -------------------------------------------------------------------- + + +def seed() -> None: + if projects: + return + t = now() + + agent_settings.update({ + "default_provider_id": "modelscope", + "default_model_id": "qwen3-max", + "default_memory_enabled": True, + "default_memory_backend": "file", + "global_mcp_auto_attach": True, + "global_skill_auto_attach": True, + }) + + profile.update({ + "agent_calls_user": "User", + "description": "", + "updated_at": t, + }) + + # Projects — default project has memory forced off per spec. + projects[DEFAULT_PROJECT_ID] = { + "id": DEFAULT_PROJECT_ID, + "name": "默认项目", + "description": + "Default workspace — its settings are the global settings.", + "local_path": "", + "is_default": True, + "memory_enabled": False, + "memory_backend": "file", + "mcp_auto_attach": True, + "skill_auto_attach": True, + "created_at": t - timedelta(days=30), + } + for pid, name, desc, path in [ + ("webui", "ms-agent-webui", "This repo", + "/Users/demo/Projects/ms-agent-webui"), + ("agent-idp", "AgentIDP", "Agent IDP project", + "/Users/demo/Projects/agent-idp"), + ("studio-preinstall", "StudioPreInstall", "Studio preinstall stack", + ""), + # Chinese-named project — id stays an opaque slug, name carries Chinese. + ("p-zh01", "智能客服助手", "中文命名示例项目", "/Users/demo/Projects/zh-support-bot" + ), + ]: + projects[pid] = { + "id": pid, + "name": name, + "description": desc, + "local_path": path, + "is_default": False, + "memory_enabled": True, + "memory_backend": "file", + "mcp_auto_attach": True, + "skill_auto_attach": True, + "created_at": t - timedelta(days=7), + } + + # Sessions — a few in default + each named project + for sid, title, project_id, mins_ago, preview in [ + ("s-001", "Bootstrap webui prototype", "webui", 5, + "Use the README and wire up the entry page"), + ("s-002", "Sort out plan-component interactions", "webui", 120, + "ask/confirm → tool-side rendering"), + ("s-003", "Implement sidebar navigation", "webui", 60 * 4, + "Collapsible sidebar with project list"), + ("s-004", "Design token integration", "webui", 60 * 6, + "Map Figma tokens to Tailwind CSS variables"), + ("s-005", "Empty state component", "webui", 60 * 8, + "Unified EmptyState with illustration and action"), + ("s-006", "Composer SSR hydration fix", "webui", 60 * 12, + "Fix textarea flash on initial load"), + ("s-007", "MCP panel card layout", "webui", 60 * 18, + "Grid cards with enable toggle and status"), + ("s-008", "Memory module refactor", "webui", 60 * 24, + "Inline editing with content-only model"), + ("s-009", "Dark mode theme switching", "webui", 60 * 36, + "Class-based dark mode with useTheme hook"), + ("s-010", "File upload interaction", "webui", 60 * 48, + "Drag-drop and click-to-upload with preview"), + ("s-011", "SVG icon componentization", "webui", 60 * 72, + "vite-plugin-svgr + currentColor migration"), + ("s-100", "Quick brainstorm", DEFAULT_PROJECT_ID, 20, + "Out-of-project chat — name ideas"), + ("s-101", "Session log research", DEFAULT_PROJECT_ID, 60 * 24, + "activity monitor + intermediate trace"), + ("s-102", "Read the antd-x docs", DEFAULT_PROJECT_ID, 60 * 6, + "Skim useXChat / x-chat-provider"), + ("s-200", "IDP feature gaps", "agent-idp", 60 * 3, + "Compare with internal IDP stack"), + ("s-300", "客服话术优化", "p-zh01", 45, "整理高频问题与标准回复"), + ("s-301", "意图识别调研", "p-zh01", 60 * 8, "对比规则匹配与小模型分类"), + ]: + sessions[sid] = { + "id": sid, + "title": title, + "project_id": project_id, + "updated_at": t - timedelta(minutes=mins_ago), + "preview": preview, + } + + artifacts["s-001"] = [ + { + "id": + "a-001", + "session_id": + "s-001", + "path": + "workspace/notes.md", + "kind": + "markdown", + "size": + 512, + "updated_at": + t - timedelta(minutes=4), + "preview": + "# Entry page TODO\n- Left sidebar\n- Centre chat\n- Right artifact panel", + }, + { + "id": "a-002", + "session_id": "s-001", + "path": "workspace/plan.json", + "kind": "json", + "size": 128, + "updated_at": t - timedelta(minutes=3), + "preview": '{"steps": ["scaffold", "ui", "wire"]}', + }, + ] + + # MCPs — a couple global + project-scoped + for name, transport, endpoint, scope, desc in [ + ("@amap/amap-maps", "stdio", "npx -y @amap/amap-maps-mcp-server", + "global", + "Use this skill whenever the user wants to do anything with map / location queries." + ), + ("@modelcontextprotocol/fetch", "stdio", + "npx -y @modelcontextprotocol/server-fetch", "global", + "Use this skill any time a URL is involved in any way — fetch / scrape / parse pages." + ), + ("@anthropic/claude-code", "stdio", + "npx -y @anthropic/claude-code-mcp", "global", + "Interact with Claude for code generation and review."), + ("@vercel/mcp-server", "streamable_http", "https://mcp.vercel.com", + "global", "Deploy and manage Vercel projects."), + ("@github/mcp-server", "stdio", "npx -y @github/mcp-server", "global", + "GitHub repository operations — issues, PRs, code search."), + ("@supabase/mcp-server", "stdio", "npx -y @supabase/mcp-server", + "global", "Supabase database and auth management."), + ("@stripe/agent-toolkit", "stdio", "npx -y @stripe/agent-toolkit-mcp", + "global", "Payment processing and subscription management."), + ("@notion/mcp-server", "stdio", "npx -y @notion/mcp-server", "global", + "Read and write Notion pages and databases."), + ("@linear/mcp-server", "stdio", "npx -y @linear/mcp-server", "global", + "Linear issue tracking and project management."), + ("@slack/mcp-server", "stdio", "npx -y @slack/mcp-server", "global", + "Send messages and manage Slack channels."), + ("@figma/mcp-server", "stdio", "npx -y @figma/mcp-server", "global", + "Access Figma designs, components, and design tokens."), + ("@sentry/mcp-server-global", "stdio", "npx -y @sentry/mcp-server", + "global", "Error monitoring and performance tracing for production."), + ("@datadog/mcp-server", "stdio", "npx -y @datadog/mcp-server", + "global", "Application monitoring, tracing, and log analytics."), + ("@twilio/mcp-server", "stdio", "npx -y @twilio/mcp-server", "global", + "SMS, voice, and messaging communication APIs."), + ("@cloudflare/mcp-server", "stdio", "npx -y @cloudflare/mcp-server", + "global", "Edge computing, DNS, and CDN management."), + ("@jira/mcp-server", "stdio", "npx -y @jira/mcp-server", "global", + "Jira issue tracking and agile project boards."), + ("@confluence/mcp-server", "stdio", "npx -y @confluence/mcp-server", + "global", "Confluence wiki page creation and search."), + ("@tavily-ai/tavily-mcp", "stdio", "npx -y @tavily-ai/tavily-mcp", + f"project:{DEFAULT_PROJECT_ID}", + "Web search and real-time information retrieval."), + ("antvis/mcp-server-chart", "stdio", "uvx mcp-server-chart", + f"project:{DEFAULT_PROJECT_ID}", + "Use this skill any time a spreadsheet file is the primary input or output." + ), + ("@prisma/mcp-server", "stdio", "npx -y @prisma/mcp-server", + f"project:{DEFAULT_PROJECT_ID}", + "Prisma ORM schema and migration management."), + ("@docker/mcp-server", "stdio", "npx -y @docker/mcp-server", + f"project:{DEFAULT_PROJECT_ID}", + "Docker container lifecycle management."), + ("@aws/mcp-server", "stdio", "npx -y @aws/mcp-server", + f"project:{DEFAULT_PROJECT_ID}", + "AWS cloud resource provisioning and monitoring."), + ("@firebase/mcp-server", "stdio", "npx -y @firebase/mcp-server", + f"project:{DEFAULT_PROJECT_ID}", + "Firebase auth, Firestore, and hosting operations."), + ("@openai/mcp-server", "stdio", "npx -y @openai/mcp-server", + f"project:{DEFAULT_PROJECT_ID}", + "OpenAI API access for embeddings and completions."), + ("@redis/mcp-server", "stdio", "npx -y @redis/mcp-server", + f"project:{DEFAULT_PROJECT_ID}", + "Redis cache and pub/sub operations."), + ("@elasticsearch/mcp-server", "stdio", + "npx -y @elasticsearch/mcp-server", f"project:{DEFAULT_PROJECT_ID}", + "Elasticsearch full-text search and analytics."), + ("@sentry/mcp-server", "stdio", "npx -y @sentry/mcp-server", + f"project:{DEFAULT_PROJECT_ID}", + "Sentry error tracking and performance monitoring."), + ("@mongodb/mcp-server", "stdio", "npx -y @mongodb/mcp-server", + f"project:{DEFAULT_PROJECT_ID}", + "MongoDB document CRUD and aggregation pipelines."), + ("@grafana/mcp-server", "stdio", "npx -y @grafana/mcp-server", + f"project:{DEFAULT_PROJECT_ID}", + "Grafana dashboard queries and alert management."), + ]: + mid = new_id("m-") + mcps[mid] = { + "id": mid, + "name": name, + "description": desc, + "transport": transport, + "endpoint": endpoint, + "enabled": True, + "scope": scope, + "created_at": t, + } + + # Skills — global + project-scoped + for name, kind, scope in [ + ("docx", "file-type", "global"), + ("pdf", "file-type", "global"), + ("xlsx", "file-type", "global"), + ("skill-creator", "meta", "global"), + ("code-review", "meta", "global"), + ("unit-test-gen", "meta", "global"), + ("api-doc-gen", "meta", "global"), + ("commit-message", "meta", "global"), + ("refactor-assist", "domain", "global"), + ("i18n-extract", "domain", "global"), + ("sql-optimizer", "domain", "global"), + ("regex-builder", "domain", "global"), + ("env-checker", "domain", "global"), + ("changelog-writer", "meta", "global"), + ("type-gen", "meta", "global"), + ("mock-data-gen", "meta", "global"), + ("git-flow-helper", "domain", "global"), + ("orbra-writing-plans", "domain", f"project:{DEFAULT_PROJECT_ID}"), + ("anthropics-front-design", "domain", f"project:{DEFAULT_PROJECT_ID}"), + ("component-scaffold", "meta", f"project:{DEFAULT_PROJECT_ID}"), + ("storybook-gen", "meta", f"project:{DEFAULT_PROJECT_ID}"), + ("e2e-test-writer", "meta", f"project:{DEFAULT_PROJECT_ID}"), + ("changelog-gen", "meta", f"project:{DEFAULT_PROJECT_ID}"), + ("migration-assist", "domain", f"project:{DEFAULT_PROJECT_ID}"), + ("perf-profiler", "domain", f"project:{DEFAULT_PROJECT_ID}"), + ("accessibility-audit", "domain", f"project:{DEFAULT_PROJECT_ID}"), + ("dep-updater", "domain", f"project:{DEFAULT_PROJECT_ID}"), + ("lint-fixer", "domain", f"project:{DEFAULT_PROJECT_ID}"), + ("docker-compose-gen", "domain", f"project:{DEFAULT_PROJECT_ID}"), + ]: + sk_id = new_id("sk-") + skills[sk_id] = { + "id": sk_id, + "name": name, + "kind": kind, + "content": f"# {name}\n\nPlaceholder skill description.", + "enabled": True, + "scope": scope, + "created_at": t, + } + + # Memory items — project-scoped only (Default project gets none). + memory_files["webui"] = [ + { + "id": "mem_001", + "project_id": "webui", + "content": + "User prefers concise replies. Default model: Qwen3-Max.", + "updated_at": t, + }, + { + "id": "mem_002", + "project_id": "webui", + "content": + "Use Tailwind utility classes. Avoid inline styles. Prefer design tokens over raw color values.", + "updated_at": t - timedelta(hours=1), + }, + { + "id": "mem_003", + "project_id": "webui", + "content": + "All buttons must use MsaButton component. IconButton for icon-only actions. No raw + )} + {chatPanel} + + + {/* Right rail: workspace or step detail, width transition. */} + {railPanel} + + + {railDrawerEl} + + + ) +} diff --git a/webui/frontend/app/components/common/CardSkeletonGrid.tsx b/webui/frontend/app/components/common/CardSkeletonGrid.tsx new file mode 100644 index 000000000..6649e0bdb --- /dev/null +++ b/webui/frontend/app/components/common/CardSkeletonGrid.tsx @@ -0,0 +1,28 @@ +import { Skeleton } from 'antd' +import { DeferredSkeleton } from './DeferredSkeleton' + +interface CardSkeletonGridProps { + /** Number of skeleton cards to render */ + count?: number + /** Grid column class names (default: 3-col responsive for settings pages) */ + className?: string +} + +/** + * CardSkeletonGrid — Loading placeholder for card grid lists. + * Used while card data is being fetched. + */ +export function CardSkeletonGrid({ + count = 6, + className = 'grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3' +}: CardSkeletonGridProps) { + return ( + + {Array.from({ length: count }).map((_, i) => ( +
+ +
+ ))} +
+ ) +} diff --git a/webui/frontend/app/components/common/CodeEditor.tsx b/webui/frontend/app/components/common/CodeEditor.tsx new file mode 100644 index 000000000..910ea6eda --- /dev/null +++ b/webui/frontend/app/components/common/CodeEditor.tsx @@ -0,0 +1,158 @@ +import { Skeleton } from 'antd' +import { lazy, Suspense, useEffect, useState } from 'react' +import { useTheme } from '~/lib/theme' + +interface Props { + value: string + onChange?: (next: string) => void + /** Monaco language id — e.g. 'json', 'markdown', 'python'. Default 'plaintext'. */ + language?: string + height?: number | string + readOnly?: boolean + /** Toggle line numbers. Off by default to match the right-rail compact look. */ + lineNumbers?: boolean + /** + * Enable the complete file-editing chrome — line numbers, folding controls, + * minimap, sticky scroll, current-line highlight and glyph margin — for real + * file editing/viewing (e.g. the workspace editor) rather than the compact + * JSON config boxes. Implies line numbers on. + */ + fullFeatures?: boolean +} + +interface InternalProps extends Props { + dark?: boolean +} + +/** + * Monaco-based code editor. + * + * - Client-only: monaco-editor pulls `window` globals at import time and + * doesn't survive SSR. We render a Skeleton on the server pass and lazy + * import the editor after mount. + * - Local loader: `@monaco-editor/react` defaults to fetching monaco from + * jsDelivr; we point it at the bundled `monaco-editor` so the app works + * offline / inside corporate networks. + * - Vite-friendly workers: monaco needs web workers for language services. + * We register the editor + JSON workers via `?worker` imports so Vite + * emits proper bundles instead of trying to fetch them at runtime. Other + * languages use the generic editor worker (no IntelliSense but full + * syntax highlighting and editing). + */ +export function CodeEditor({ + value, + onChange, + language = 'plaintext', + height = 320, + readOnly, + lineNumbers, + fullFeatures +}: Props) { + const [mounted, setMounted] = useState(false) + const { theme } = useTheme() + const dark = theme === 'dark' + + useEffect(() => { + setMounted(true) + }, []) + + if (!mounted) { + return + } + + return ( + }> + + + ) +} + +const LazyEditor = lazy(async () => { + const [monaco, mod, EditorWorker, JsonWorker] = await Promise.all([ + import('monaco-editor'), + import('@monaco-editor/react'), + import('monaco-editor/esm/vs/editor/editor.worker?worker'), + import('monaco-editor/esm/vs/language/json/json.worker?worker') + ]) + + ;(globalThis as { MonacoEnvironment?: unknown }).MonacoEnvironment = { + getWorker(_workerId: string, label: string) { + if (label === 'json') return new JsonWorker.default() + return new EditorWorker.default() + } + } + + mod.loader.config({ monaco }) + + return { + default: (p: InternalProps) => { + const full = p.fullFeatures ?? false + return ( + p.onChange?.(v ?? '')} + height={p.height} + language={p.language} + theme={p.dark ? 'vs-dark' : 'vs'} + options={{ + readOnly: p.readOnly, + // Reflow when the container resizes (e.g. dragging the workspace + // splitter or resizing the window). + automaticLayout: true, + scrollBeyondLastLine: false, + fontSize: full ? 13 : 12, + fontLigatures: true, + tabSize: 2, + wordWrap: 'on', + padding: { top: 8, bottom: 8 }, + // Editing quality-of-life — useful in every scenario. + bracketPairColorization: { enabled: true }, + matchBrackets: 'always', + autoClosingBrackets: 'languageDefined', + autoClosingQuotes: 'languageDefined', + autoSurround: 'languageDefined', + autoIndent: 'full', + formatOnPaste: true, + guides: { indentation: true, bracketPairs: true }, + cursorBlinking: 'smooth', + cursorSmoothCaretAnimation: 'on', + smoothScrolling: true, + mouseWheelZoom: true, + multiCursorModifier: 'ctrlCmd', + find: { seedSearchStringFromSelection: 'selection' }, + scrollbar: { + useShadows: false, + verticalScrollbarSize: 10, + horizontalScrollbarSize: 10 + }, + // Full file-editing chrome vs. compact config box. + lineNumbers: full || p.lineNumbers ? 'on' : 'off', + folding: true, + foldingHighlight: true, + showFoldingControls: full ? 'always' : 'mouseover', + glyphMargin: full, + lineDecorationsWidth: full ? 10 : 0, + stickyScroll: { enabled: full }, + renderLineHighlight: full ? 'all' : 'line', + occurrencesHighlight: full ? 'singleFile' : 'off', + minimap: { + enabled: full, + autohide: 'mouseover', + renderCharacters: false, + maxColumn: 80 + } + }} + /> + ) + } + } +}) diff --git a/webui/frontend/app/components/common/Composer.css b/webui/frontend/app/components/common/Composer.css new file mode 100644 index 000000000..b8d952176 --- /dev/null +++ b/webui/frontend/app/components/common/Composer.css @@ -0,0 +1,153 @@ +/* ================================================================ + * Composer gradient border on focus + * ================================================================ */ +.composer-card { + position: relative; +} + +.composer-card::before { + content: ''; + position: absolute; + inset: -3px; + border-radius: 16px; + padding: 3px; + background: conic-gradient(from var(--gradient-angle, 0deg), #90DFE5, #A288FF, #90DFE5); + -webkit-mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease; + animation: gradient-spin 3s linear infinite; +} + +.composer-card:focus-within::before { + opacity: 1; +} + +@property --gradient-angle { + syntax: ''; + initial-value: 0deg; + inherits: false; +} + +@keyframes gradient-spin { + to { + --gradient-angle: 360deg; + } +} + +/* ================================================================ + * Thinking state wrapper + * ================================================================ */ +.composer-thinking-wrapper { + /* New stacking context so the motion layer can sit at z-index:-1 — + above this element's own gradient background, below its content. */ + isolation: isolate; + background: + linear-gradient(345deg, #C1C1FF -29%, rgba(193, 193, 255, 0) 33%), + linear-gradient(170deg, #00F0F3 -23%, rgba(255, 255, 255, 0) 19%), + #EFF2F9; +} + +.dark .composer-thinking-wrapper { + background: + linear-gradient(345deg, rgba(98, 74, 255, 0.3) -29%, rgba(98, 74, 255, 0) 33%), + linear-gradient(170deg, rgba(0, 240, 243, 0.15) -23%, rgba(0, 240, 243, 0) 19%), + #1c1c2e; +} + +/* ================================================================ + * Live-turn ambient motion: six blurred color blobs drifting inside the + * thinking wrapper while a turn streams (Figma motion spec, 8s loop). + * Idle keeps the static gradient above — the layer only mounts while live. + * ================================================================ */ +.composer-motion-layer { + position: absolute; + inset: 0; + overflow: hidden; + border-radius: 24px; + pointer-events: none; + z-index: -1; +} + +.composer-motion-layer span { + position: absolute; + left: 0; + top: 0; + width: 320px; + height: 240px; + border-radius: 50%; + filter: blur(60px); + will-change: transform; + animation-duration: 8s; + animation-timing-function: ease-in-out; + animation-iteration-count: infinite; +} + +/* Palette mirrors the static gradient (cyan + violet family). */ +.composer-motion-layer span:nth-child(1) { background: rgba(0, 240, 243, 0.35); animation-name: composer-blob-1; } +.composer-motion-layer span:nth-child(2) { background: rgba(193, 193, 255, 0.55); animation-name: composer-blob-2; } +.composer-motion-layer span:nth-child(3) { background: rgba(162, 136, 255, 0.35); animation-name: composer-blob-3; } +.composer-motion-layer span:nth-child(4) { background: rgba(0, 240, 243, 0.22); animation-name: composer-blob-4; } +.composer-motion-layer span:nth-child(5) { background: rgba(193, 193, 255, 0.45); animation-name: composer-blob-5; } +.composer-motion-layer span:nth-child(6) { background: rgba(144, 223, 229, 0.35); animation-name: composer-blob-6; } + +.dark .composer-motion-layer span { opacity: 0.45; } + +/* Keyframes transcribed from the Figma motion JSON (nodes 15:603..608): + 4 waypoints per blob over ~8s, looping back to the start. The extra + translate(-50%, -50%) centers each blob on its waypoint. */ +@keyframes composer-blob-1 { + 0% { transform: translate(641px, 112px) translate(-50%, -50%); } + 25% { transform: translate(700px, -72px) translate(-50%, -50%); } + 50% { transform: translate(270px, -112px) translate(-50%, -50%); } + 75% { transform: translate(11px, 93px) translate(-50%, -50%); } + 100% { transform: translate(641px, 112px) translate(-50%, -50%); } +} + +@keyframes composer-blob-2 { + 0% { transform: translate(290px, -42px) translate(-50%, -50%); } + 25% { transform: translate(-28px, -42px) translate(-50%, -50%); } + 50% { transform: translate(122px, 98px) translate(-50%, -50%); } + 75% { transform: translate(543px, 130px) translate(-50%, -50%); } + 100% { transform: translate(290px, -42px) translate(-50%, -50%); } +} + +@keyframes composer-blob-3 { + 0% { transform: translate(-125px, 113px) translate(-50%, -50%); } + 25% { transform: translate(293px, 113px) translate(-50%, -50%); } + 50% { transform: translate(526px, 98px) translate(-50%, -50%); } + 75% { transform: translate(295px, -71px) translate(-50%, -50%); } + 100% { transform: translate(-125px, 113px) translate(-50%, -50%); } +} + +@keyframes composer-blob-4 { + 0% { transform: translate(-21px, -52px) translate(-50%, -50%); } + 25% { transform: translate(-21px, 84px) translate(-50%, -50%); } + 50% { transform: translate(-124px, -57px) translate(-50%, -50%); } + 75% { transform: translate(167px, -72px) translate(-50%, -50%); } + 100% { transform: translate(-21px, -52px) translate(-50%, -50%); } +} + +@keyframes composer-blob-5 { + 0% { transform: translate(116px, 53px) translate(-50%, -50%); } + 25% { transform: translate(426px, 53px) translate(-50%, -50%); } + 50% { transform: translate(635px, -74px) translate(-50%, -50%); } + 75% { transform: translate(-32px, -119px) translate(-50%, -50%); } + 100% { transform: translate(116px, 53px) translate(-50%, -50%); } +} + +@keyframes composer-blob-6 { + 0% { transform: translate(587px, -96px) translate(-50%, -50%); } + 25% { transform: translate(260px, -96px) translate(-50%, -50%); } + 50% { transform: translate(98px, 0px) translate(-50%, -50%); } + 75% { transform: translate(568px, 89px) translate(-50%, -50%); } + 100% { transform: translate(587px, -96px) translate(-50%, -50%); } +} + +@media (prefers-reduced-motion: reduce) { + .composer-motion-layer span { animation: none; } +} \ No newline at end of file diff --git a/webui/frontend/app/components/common/Composer.tsx b/webui/frontend/app/components/common/Composer.tsx new file mode 100644 index 000000000..deffebefb --- /dev/null +++ b/webui/frontend/app/components/common/Composer.tsx @@ -0,0 +1,1108 @@ +import { App, Button, Dropdown, Tooltip, Typography } from 'antd' +import type { MenuProps } from 'antd' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { taskStatusIcon } from '~/components/messages/TaskPlan' +import IconFolder from '~/assets/icons/folder.svg?react' +import IconTask from '~/assets/icons/task.svg?react' +import { NewProjectModal } from '~/components/project/NewProjectModal' +import { PillButton } from './PillButton' +import { api } from '~/lib/api' +import { useOnMcpSkillChanged, dispatchWorkspaceChanged } from '~/lib/events' +import type { ChatFileRef } from '~/lib/agentProvider' +import { useT } from '~/lib/i18n' +import type { + AgentSettings, + Mcp, + Model, + PermissionMode, + Project, + Provider, + Scope, + Skill +} from '~/lib/types' +import { FileCard, FileTypeIcon, fileToAttached } from './FileCard' +import type { AttachedFile } from './FileCard' +import { IconButton } from './IconButton' +import { StableSender } from './StableSender' +import type { SenderHandle } from './StableSender' +import type { SlotConfigType } from '@ant-design/x/es/sender' +import type { MessageSegment } from '~/lib/agentProvider' +import { ModelSelector } from './ModelSelector' +import { McpSelector } from './McpSelector' +import { SkillSelector } from './SkillSelector' +import './Composer.css' +import ExpandIcon from '~/assets/icons/expand.svg?react' +import CaretDownIcon from '~/assets/icons/chevron-down.svg?react' +import AddIcon from '~/assets/icons/add.svg?react' +import FolderIcon from '~/assets/icons/folder.svg?react' +import SendIcon from '~/assets/icons/send.svg?react' +import MoreIcon from '~/assets/icons/more.svg?react' + +// Sender runs PERMANENTLY in slot mode (structured input): picked skills are +// inline tag pills among free text. Stable module-level empty config (the +// Sender rebuilds slot state on reference change) — slots are inserted +// imperatively via ref.insert(). +const ALWAYS_SLOT_MODE: SlotConfigType[] = [] + +export interface ThinkingTask { + id: string + label: string + status: 'done' | 'running' | 'pending' | 'waiting' +} + +export interface ThinkingFile { + id: string + name: string + type: 'file' | 'image' | 'video' + /** Workspace-relative path — clicking the row opens it (via onOpenFile). */ + path?: string + /** Written during the session but no longer on disk (shows a badge, + * not clickable). */ + deleted?: boolean +} + +export interface ThinkingState { + tasks: ThinkingTask[] + agentName?: string + /** True only when the CURRENT turn's stream has reported plan activity — + * gates the running spinner / "executing task" caption so a stale + * plan-file "running" row doesn't animate during an unrelated turn. */ + planActive?: boolean + files?: ThinkingFile[] +} + +interface ComposerProps { + onSubmit: ( + text: string, + files?: ChatFileRef[], + /** Ordered configuration-style segments (text + skill pills) exactly as + * laid out in the input — present only when at least one pill was used. */ + segments?: MessageSegment[] + ) => void + loading?: boolean + onCancel?: () => void + /** Fired whenever the textarea value changes (used to snap the message list + * to the bottom the moment the user starts typing). */ + onType?: () => void + placeholder?: string + autoSize?: { minRows?: number; maxRows?: number } + project?: Project | null + onProjectChange?: (projectId: string | null) => void + attachable?: boolean + + /** Thinking state: shows gradient wrapper with task list */ + thinking?: ThinkingState | null + /** Opens a session-produced file in the workspace rail (file list rows). */ + onOpenFile?: (path: string) => void +} + +/** Skill-suggestion description: single-line, ellipsized when it overflows the + * panel. When (and only when) it's actually clipped, hovering reveals the full + * text in a tooltip. Truncation is measured on hover (cheap — one item at a + * time), so no observers run for the whole list. */ +function SuggestionDesc({ text }: { text: string }) { + const ref = useRef(null) + const [clipped, setClipped] = useState(false) + return ( + + { + const el = ref.current + if (el) setClipped(el.scrollWidth > el.clientWidth) + }} + className="min-w-0 flex-1 truncate text-xs text-msa-text-3" + > + {text} + + + ) +} + +export function Composer({ + onSubmit, + loading = false, + onCancel, + onType, + placeholder, + autoSize: autoSizeProp, + project, + onProjectChange, + attachable = true, + thinking = null, + onOpenFile +}: ComposerProps) { + const autoSize = autoSizeProp ?? { minRows: 1, maxRows: 6 } + const { t } = useT() + const { message } = App.useApp() + + // Prevent SSR hydration flash: fix height until client mount + const [mounted, setMounted] = useState(false) + useEffect(() => { + setMounted(true) + }, []) + + const [draft, setDraft] = useState('') + const [files, setFiles] = useState([]) + const fileInputRef = useRef(null) + // Todo plan starts collapsed; the user expands it via the header chevron. + const [thinkingExpanded, setThinkingExpanded] = useState(false) + // The file list starts collapsed too — both sections open on demand only. + const [filesExpanded, setFilesExpanded] = useState(false) + // Drives the project-picker caret flip (antd Dropdown owns the panel). + const [projectMenuOpen, setProjectMenuOpen] = useState(false) + // Project-level authorization mode selector (restricted / full access). + const [permMenuOpen, setPermMenuOpen] = useState(false) + // Optimistic override after a switch — reset when the project changes. + const [permModeLocal, setPermModeLocal] = useState( + null + ) + // Small screen ((null) + useEffect(() => { + if (!pillsExpanded) return + const handleClickOutside = (e: MouseEvent) => { + if (pillsRef.current && !pillsRef.current.contains(e.target as Node)) { + setPillsExpanded(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [pillsExpanded]) + + const [projects, setProjects] = useState([]) + const [createOpen, setCreateOpen] = useState(false) + const [pickedProjectId, setPickedProjectId] = useState(null) + + // The project whose MCP/Skill/auto config applies: the route-level project, + // or — on the homepage — the one chosen in the picker dropdown. + const effectiveProject = useMemo( + () => project ?? projects.find((p) => p.id === pickedProjectId) ?? null, + [project, projects, pickedProjectId] + ) + + // null = not loaded yet, so the model pill's panel shows a skeleton instead + // of "no models" before the lists arrive. + const [models, setModels] = useState(null) + const [providers, setProviders] = useState(null) + const [settings, setSettings] = useState(null) + // Fetch MCP/skill lists (global + project-scoped) directly. No shared + // context — each Composer mount fetches fresh data for its project, merged. + const [globalMcps, setGlobalMcps] = useState([]) + const [globalSkills, setGlobalSkills] = useState([]) + const [projectMcps, setProjectMcps] = useState([]) + const [projectSkills, setProjectSkills] = useState([]) + useEffect(() => { + api + .listMcps('global') + .then(setGlobalMcps) + .catch(() => setGlobalMcps([])) + api + .listSkills('global') + .then(setGlobalSkills) + .catch(() => setGlobalSkills([])) + if (effectiveProject) { + const scope: Scope = `project:${effectiveProject.id}` + api + .listMcps(scope) + .then(setProjectMcps) + .catch(() => setProjectMcps([])) + api + .listSkills(scope) + .then(setProjectSkills) + .catch(() => setProjectSkills([])) + } else { + setProjectMcps([]) + setProjectSkills([]) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [effectiveProject?.id]) + + // Re-fetch when MCP/Skill config changes on the same page (e.g. toggled in + // the project-detail MCPs tab while Composer is mounted above). + const refreshMcpSkill = useCallback(() => { + api + .listMcps('global') + .then(setGlobalMcps) + .catch(() => setGlobalMcps([])) + api + .listSkills('global') + .then(setGlobalSkills) + .catch(() => setGlobalSkills([])) + if (effectiveProject) { + const scope: Scope = `project:${effectiveProject.id}` + api + .listMcps(scope) + .then(setProjectMcps) + .catch(() => setProjectMcps([])) + api + .listSkills(scope) + .then(setProjectSkills) + .catch(() => setProjectSkills([])) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [effectiveProject?.id]) + useOnMcpSkillChanged(refreshMcpSkill) + + const mergedMcps = useMemo( + () => [...globalMcps, ...projectMcps], + [globalMcps, projectMcps] + ) + const mergedSkills = useMemo( + () => [...globalSkills, ...projectSkills], + [globalSkills, projectSkills] + ) + + const hasProjectPicker = !project && !!onProjectChange + + useEffect(() => { + Promise.all([ + api.listProviders(), + api.listModels(), + api.getAgentSettings() + ]).then(([ps, ms, s]) => { + setProviders(ps) + setModels(ms) + setSettings(s) + }) + if (hasProjectPicker) { + api.listProjects().then(setProjects) + } + }, [hasProjectPicker]) + + const updateSettings = async (patch: Partial) => { + if (!settings) return + const next = await api.putAgentSettings({ ...settings, ...patch }) + setSettings(next) + } + + const skillSuggestions = useMemo( + () => + mergedSkills + .filter((s) => s.enabled) + .map((s) => ({ + id: s.id, + name: s.name, + value: `/${s.name}`, + label: `/${s.name}`, + // One-liner description shown beside the name (same derivation as + // SkillCard: first non-heading line of the skill content, which is + // the SKILL.md description for discovered/built-in skills). + desc: + (s.content || '') + .split('\n') + .map((line) => line.trim()) + .find((line) => line && !line.startsWith('#')) ?? '' + })), + [mergedSkills] + ) + + // Skills the user picked from the slash dropdown, inserted into the + // always-slot-mode Sender as inline tag pills. Repeatable: each pick gets a + // UNIQUE slot key (same skill can appear multiple times, earlier pills are + // never touched). Backspacing a pill away drops it from this list (synced + // in onChange via the surviving slot keys). + const [pickedSkills, setPickedSkills] = useState< + { key: string; id: string; name: string }[] + >([]) + const skillSeqRef = useRef(0) + const senderRef = useRef(null) + + // ---- Slash-command suggestion panel ---- + const [suggestOpen, setSuggestOpen] = useState(false) + const [suggestIndex, setSuggestIndex] = useState(0) + const suggestOpenRef = useRef(false) + + const filteredSuggestions = useMemo(() => { + if (!suggestOpen) return [] + const slashIdx = draft.lastIndexOf('/') + if (slashIdx < 0) return skillSuggestions + const query = draft.slice(slashIdx + 1).toLowerCase() + return skillSuggestions.filter((s) => s.value.toLowerCase().includes(query)) + }, [suggestOpen, draft, skillSuggestions]) + + const openSuggestions = useCallback(() => { + setSuggestOpen(true) + setSuggestIndex(0) + suggestOpenRef.current = true + }, []) + + const closeSuggestions = useCallback(() => { + setSuggestOpen(false) + suggestOpenRef.current = false + }, []) + + const selectSuggestion = useCallback( + (item: { id: string; name: string; value: string }) => { + // Replace the trailing `/query` the user was typing with an inline tag + // pill, in place (Sender is permanently in slot mode — no remount, the + // rest of the draft is untouched). Each insertion gets a unique slot + // key so repeated picks coexist instead of clobbering earlier pills. + // `formatResult: ''` keeps the pill out of the plain-text value; ids + // travel via `pickedSkills`. + const slotKey = `skill-${item.id}-${skillSeqRef.current++}` + const slashIdx = draft.lastIndexOf('/') + const replaceChars = slashIdx >= 0 ? draft.slice(slashIdx) : '' + senderRef.current?.insert( + [ + { + type: 'tag', + key: slotKey, + props: { label: item.value, value: item.id }, + formatResult: () => '' + } + ], + 'cursor', + replaceChars || undefined + ) + setPickedSkills((prev) => [ + ...prev, + { key: slotKey, id: item.id, name: item.name } + ]) + closeSuggestions() + }, + [draft, closeSuggestions] + ) + + const handleSuggestionKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!suggestOpenRef.current || filteredSuggestions.length === 0) return + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setSuggestIndex((i) => (i + 1) % filteredSuggestions.length) + break + case 'ArrowUp': + e.preventDefault() + setSuggestIndex( + (i) => + (i - 1 + filteredSuggestions.length) % filteredSuggestions.length + ) + break + case 'Enter': + e.preventDefault() + e.stopPropagation() + selectSuggestion(filteredSuggestions[suggestIndex]) + break + case 'Escape': + e.preventDefault() + closeSuggestions() + break + } + }, + [filteredSuggestions, suggestIndex, selectSuggestion, closeSuggestions] + ) + + const defaultProject = useMemo( + () => projects.find((p) => p.is_default) ?? null, + [projects] + ) + + // Default the picker to the default project when nothing is picked yet. + useEffect(() => { + if (hasProjectPicker && defaultProject && pickedProjectId === null) { + setPickedProjectId(defaultProject.id) + onProjectChange?.(defaultProject.id) + } + }, [hasProjectPicker, defaultProject, pickedProjectId, onProjectChange]) + + // The project files land in: fixed project, or the picked one on the homepage + // new-chat (defaulted to the default project by the effect above). + const effectiveProjectId = project?.id ?? pickedProjectId + + // Authorization mode of the effective project (optimistic local override + // wins until the project changes). Persisted project-side; live runtimes are + // hot-switched by the backend, so it applies to the current turn's next call. + const permMode: PermissionMode = + permModeLocal ?? effectiveProject?.permission_mode ?? 'restricted' + + useEffect(() => { + setPermModeLocal(null) + }, [effectiveProjectId]) + + const switchPermMode = async (mode: PermissionMode) => { + if (!effectiveProjectId || mode === permMode) return + setPermModeLocal(mode) + try { + await api.updateProject(effectiveProjectId, { permission_mode: mode }) + } catch { + setPermModeLocal(null) // global error toast handles the message + } + } + + const MAX_FILES = 10 + + // Upload one attached file to /user_files/ immediately on selection, + // then stamp the real (deduped) path + raw URL the backend returns. Failures + // flip the card to an 'error' state that the user can retry. + const uploadOne = useCallback( + async (att: AttachedFile) => { + if (!effectiveProjectId) { + setFiles((prev) => + prev.map((f) => (f.id === att.id ? { ...f, status: 'error' } : f)) + ) + return + } + setFiles((prev) => + prev.map((f) => (f.id === att.id ? { ...f, status: 'uploading' } : f)) + ) + try { + const res = await api.uploadWorkspaceFile( + effectiveProjectId, + att.file, + `user_files/${att.file.name}`, + { dedup: true } + ) + const url = api.workspaceFileRawUrl(effectiveProjectId, res.path) + setFiles((prev) => + prev.map((f) => + f.id === att.id ? { ...f, status: 'done', path: res.path, url } : f + ) + ) + // The upload landed a new file in the workspace (user_files/…) — tell + // the workspace panel / file tree so it shows up without a manual + // refresh. Pass the real deduped path as an optimistic "exists" hint. + dispatchWorkspaceChanged([res.path]) + } catch { + setFiles((prev) => + prev.map((f) => (f.id === att.id ? { ...f, status: 'error' } : f)) + ) + } + }, + [effectiveProjectId] + ) + + const hasUploading = files.some((f) => f.status === 'uploading') + const hasReadyFiles = files.some((f) => f.status === 'done') + // Send is allowed when nothing is still uploading and there is text, a + // ready file, or a picked skill pill (a bare skill invocation is valid — + // the backend answers with the skill intro). + const canSend = + !hasUploading && + (!!draft.trim() || hasReadyFiles || pickedSkills.length > 0) + + const handleSubmit = (value: string) => { + const text = value.trim() + if (hasUploading) return + const ready = files.filter((f) => f.status === 'done' && f.path) + if (!text && ready.length === 0 && pickedSkills.length === 0) return + const refs: ChatFileRef[] = ready.map((f) => ({ + name: f.name, + path: f.path!, + url: f.url, + size: f.byte, + type: f.type + })) + // Rebuild the ORDERED segments from the editable area's document-order + // slot structure (free text nodes interleaved with skill pills) — the + // flat (text, ids) pair would lose the interleaving. + const slotCfg = senderRef.current?.getValue()?.slotConfig ?? [] + const segments: MessageSegment[] = [] + for (const node of slotCfg) { + if (node.type === 'text') { + const t = String((node as { value?: unknown }).value ?? '').trim() + if (t) segments.push({ type: 'text', text: t }) + } else if (node.type === 'tag') { + const pick = pickedSkills.find((p) => p.key === node.key) + if (pick) segments.push({ type: 'skill', id: pick.id, name: pick.name }) + } + } + const hasSkill = segments.some((s) => s.type === 'skill') + onSubmit(text, refs, hasSkill ? segments : undefined) + setDraft('') + setFiles([]) + setPickedSkills([]) + // Slot mode is uncontrolled; clear the editable area imperatively. + senderRef.current?.clear() + } + + /** Queue files as attachments and start their uploads. Shared by the picker + * button, and by pasting (screenshots / files from the OS clipboard) — both + * must honour the same MAX_FILES cap and upload-on-select behaviour. The cap + * toast only ever fires for paste: the picker button is disabled at the cap, + * so a silent no-op there would be invisible. */ + const addFiles = useCallback( + (incoming: FileList | File[]) => { + const list = Array.from(incoming) + if (list.length === 0) return + const remaining = MAX_FILES - files.length + if (remaining <= 0) { + message.warning(t.home.maxFilesReached) + return + } + const added = list.slice(0, remaining).map(fileToAttached) + setFiles((prev) => [...prev, ...added]) + // Upload each immediately so the real link exists before send. + added.forEach((att) => void uploadOne(att)) + }, + [files.length, uploadOne, message, t] + ) + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files) addFiles(e.target.files) + e.target.value = '' + } + + const isMaxFiles = files.length >= MAX_FILES + + const removeFile = (id: string) => { + setFiles((prev) => prev.filter((f) => f.id !== id)) + } + + const projectMenuItems: MenuProps['items'] = hasProjectPicker + ? [ + ...(defaultProject + ? [ + { + key: defaultProject.id, + icon: , + label: defaultProject.name, + onClick: () => { + setPickedProjectId(defaultProject.id) + onProjectChange?.(defaultProject.id) + } + }, + { type: 'divider' as const } + ] + : []), + ...projects + .filter((p) => !p.is_default) + .map((p) => ({ + key: p.id, + icon: , + label: p.name, + onClick: () => { + setPickedProjectId(p.id) + onProjectChange?.(p.id) + } + })), + { type: 'divider' as const }, + { + key: '__create__', + icon: , + label: t.home.createProject, + onClick: () => setCreateOpen(true) + } + ] + : undefined + + // Shared design-spec status glyphs (single source in TaskPlan). `loading` + // gates the running spinner — a "running" item with no live turn is stale + // plan-file state and degrades to the paused glyph. + + const doneCount = + thinking?.tasks.filter((t) => t.status === 'done').length ?? 0 + const totalCount = thinking?.tasks.length ?? 0 + // "Running" is only trusted while THIS turn actually reported plan + // activity: the plan file keeps its last "in_progress" row across turns + // (interrupted or unrelated ones), and animating it on every send reads as + // phantom activity. + const planLive = loading && (thinking?.planActive ?? false) + const runningTask = planLive + ? thinking?.tasks.find((t) => t.status === 'running') + : undefined + + return ( + <> + {/* Outer wrapper: gradient when thinking, plain otherwise. + Non-thinking keeps 3px padding so the card's focus glow (::before at + inset:-3px) stays inside this box and never bleeds out to trigger a + scrollbar on an ancestor scroll container. */} +
+ {/* Ambient motion while a task is ACTUALLY running this turn (same + planActive gate as the spinner). Fades in/out via opacity transition + so it never "pops" on/off. */} + {thinking && ( +
+ + + + + + +
+ )} + {/* Thinking header + task list. The plan now renders inline in the + conversation (TaskPlan) in stream order, so it is no longer pinned + here — this only shows if a plan is explicitly present. Each + section wraps header+body in ONE flex child so a collapsed body + doesn't eat an extra gap slot (the design's tight spacing). */} + {thinking && thinking.tasks.length > 0 && ( +
+
+ {/* The whole text block toggles the accordion, not just the + chevron. */} + +
+ {runningTask && ( + + + {t.home.runningTask} + {runningTask.label} + + + )} +
+
+ +
+
+ {/* Expanded task list sits in its own bordered container + (design spec: fill-1 bg, line-1 border, 24px radius). */} +
+
+ {thinking.tasks.map((task, i) => ( +
+ {/* Status circle + dashed connector to the next item + (same timeline styling as the in-chat TaskPlan). */} +
+ {/* overflow-hidden: the spinning icon's rotated + bounding box would otherwise extend the scroll + area and summon a scrollbar. */} + + {taskStatusIcon(task.status, planLive)} + + {i < thinking.tasks.length - 1 && ( + + )} +
+ + {task.label} + +
+ ))} +
+
+
+
+
+ )} + + {/* File list (optional, inside thinking wrapper) */} + {thinking?.files && thinking.files.length > 0 && ( +
+ {/* The whole text block toggles the accordion, not just the + chevron. */} + +
+
+
+ {thinking.files.map((f) => { + const clickable = !f.deleted && !!f.path && !!onOpenFile + return ( +
onOpenFile?.(f.path!) : undefined + } + > + + + {f.name} + + {f.deleted && ( + + {t.home.fileDeleted} + + )} +
+ ) + })} +
+
+
+
+ )} + + {/* Card-style composer container */} +
+ {/* Project picker (top-right, outside card flow) */} + {hasProjectPicker && projectMenuItems && ( +
+ + + +
+ )} + + {/* Sender (textarea) with slash-command suggestion */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
+ 0} + placement="top" + autoAdjustOverflow={false} + popupRender={() => ( +
+ {filteredSuggestions.map((item, idx) => ( +
setSuggestIndex(idx)} + onMouseDown={(e) => { + e.preventDefault() + selectSuggestion(item) + }} + > + {item.label} + {item.desc && } +
+ ))} +
+ )} + > +
+ { + setDraft(v) + // Backspacing a pill away cancels that pick — keep only + // entries whose slot key survived in the editable area. + setPickedSkills((prev) => { + if (prev.length === 0) return prev + const alive = new Set( + (slotCfg ?? []) + .filter((s) => s.type === 'tag') + .map((s) => s.key) + ) + const next = prev.filter((p) => alive.has(p.key)) + return next.length === prev.length ? prev : next + }) + onType?.() + if (v === '/' || v.endsWith(' /')) { + openSuggestions() + } else if (!v.includes('/')) { + closeSuggestions() + } + }} + onSubmit={handleSubmit} + // Pasted screenshots / OS-clipboard files become attachments, + // same pipeline as the picker button. (Rich text needs no + // handling: Sender's slot-mode paste already inserts only + // text/plain, so styles never enter the editable area.) + onPasteFile={addFiles} + onKeyDown={(e) => { + // Suggestion open: let Enter pick a suggestion instead. + if ( + suggestOpenRef.current && + filteredSuggestions.length > 0 + ) + return + // Skip while IME composing. + if (e.nativeEvent.isComposing) return + if ( + e.key === 'Enter' && + !e.shiftKey && + !e.metaKey && + !e.ctrlKey && + !e.altKey + ) { + e.preventDefault() + // While a reply is streaming, Enter must not send new + // content; the user has to stop first. + if (loading) return false + handleSubmit(draft) + return false + } + }} + onCancel={onCancel} + loading={loading} + placeholder={placeholder ?? t.home.placeholder} + autoSize={autoSize} + suffix={false} + className="!border-none !bg-transparent !shadow-none !p-0" + styles={{ + input: !mounted + ? { + height: (autoSize.minRows || 1) * 14 + } + : undefined + }} + classNames={{ + input: '!bg-transparent outline-none', + content: '!p-0', + footer: '!p-0' + }} + header={ + files.length > 0 ? ( +
+ {files.map((f) => ( + void uploadOne(f)} + removable + onRemove={() => removeFile(f.id)} + /> + ))} +
+ ) : undefined + } + footer={ + // @container: makes this footer an inline-size query + // container so the pills can cap their width relative to the + // composer column (cqw), not the viewport — the composer can + // be narrow while the viewport stays wide (e.g. a detail rail + // is open), so a viewport-relative cap would overflow. +
+ {/* Left: pills. Collapsed behind a toggle on =md. Visibility is CSS-driven (md: classes) so the first + paint is correct with no SSR/hydration flash. When expanded + on small screens the group floats above the row. */} +
+ {/* Toggle button: shown only on } + onClick={() => setPillsExpanded(true)} + /> + )} + + {/* Pills: hidden on =md */} +
+ {/* Model pill */} + + updateSettings({ + default_provider_id: providerId, + default_model_id: modelId + }) + } + /> + + {/* MCP pill */} + + + {/* Skills pill */} + + + {/* Project-level authorization mode — same pill as + the model/MCP/skill selectors. Label IS the mode; + switching persists to the project and hot-applies + to live runtimes. */} + switchPermMode('restricted') + }, + { + key: 'auto', + label: t.home.permFullAccess, + onClick: () => switchPermMode('auto') + } + ] + }} + > + + {permMode === 'auto' + ? t.home.permFullAccess + : t.home.permAlwaysAsk} + + +
+
+ + {/* Right: attach + send */} +
+ {attachable && ( + <> + + + } + onClick={() => fileInputRef.current?.click()} + disabled={isMaxFiles} + /> + + + )} + + {loading ? ( + + } + onClick={() => onCancel?.()} + /> + ) : ( + } + onClick={() => handleSubmit(draft)} + disabled={!canSend} + /> + )} + +
+
+ } + /> +
+
+
+
+
+ + {hasProjectPicker && ( + setCreateOpen(false)} + onCreated={(p) => { + setCreateOpen(false) + setProjects((prev) => [...prev, p]) + setPickedProjectId(p.id) + onProjectChange?.(p.id) + }} + /> + )} + + ) +} diff --git a/webui/frontend/app/components/common/DeferredSkeleton.tsx b/webui/frontend/app/components/common/DeferredSkeleton.tsx new file mode 100644 index 000000000..4e22dbd4b --- /dev/null +++ b/webui/frontend/app/components/common/DeferredSkeleton.tsx @@ -0,0 +1,32 @@ +import { Skeleton } from 'antd' + +/** + * DeferredSkeleton — the single entry point for loading skeletons. + * + * Every skeleton renders inside the anti-flicker gate (`.msa-loading-defer`, + * app.css): invisible for the first 250ms — a fast load never flashes a + * skeleton at all — then a 150ms fade-in for genuinely slow loads. Pure CSS, + * so it is SSR-safe (no hydration timing). + * + * Two shapes: + * - default: a standard antd paragraph skeleton (`rows`); + * - `children`: a custom skeleton structure (card grids, table mocks, …) that + * only needs the gate, not the default paragraph. + */ +export function DeferredSkeleton({ + rows = 6, + className = '', + children +}: { + /** Paragraph rows for the default antd skeleton (ignored with children). */ + rows?: number + /** Extra classes on the gate wrapper (layout/padding of the placeholder). */ + className?: string + children?: React.ReactNode +}) { + return ( +
+ {children ?? } +
+ ) +} diff --git a/webui/frontend/app/components/common/EmptyState.tsx b/webui/frontend/app/components/common/EmptyState.tsx new file mode 100644 index 000000000..52a2c931b --- /dev/null +++ b/webui/frontend/app/components/common/EmptyState.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react' +import emptyLight from '~/assets/images/empty-light.png' +import emptyDark from '~/assets/images/empty-dark.png' +import { useTheme } from '~/lib/theme' + +export type EmptyStateSize = 'sm' | 'md' | 'lg' + +const IMG_SIZE: Record = { + sm: 'h-[160px]', + md: 'h-[200px]', + lg: 'h-[240px]' +} + +const PADDING: Record = { + sm: 'py-6', + md: 'py-10', + lg: 'py-16' +} + +interface Props { + /** Image & spacing size variant */ + size?: EmptyStateSize + /** Description text below the empty icon */ + description?: string + /** Optional action button rendered below the description */ + action?: ReactNode + /** Custom className for outer container */ + className?: string +} + +/** + * EmptyState — Unified empty state component. + * + * Shows a fixed empty-box illustration, an optional description, + * and an optional action button (passed in as ReactNode). + */ +export function EmptyState({ + size = 'md', + description, + action, + className = '' +}: Props) { + const { theme } = useTheme() + + return ( +
+ + {description && ( +

{description}

+ )} + {action &&
{action}
} +
+ ) +} diff --git a/webui/frontend/app/components/common/FileCard.tsx b/webui/frontend/app/components/common/FileCard.tsx new file mode 100644 index 000000000..79474e25b --- /dev/null +++ b/webui/frontend/app/components/common/FileCard.tsx @@ -0,0 +1,428 @@ +import { Image, Tooltip } from 'antd' +import type React from 'react' +import { useT } from '~/lib/i18n' + +// File type icons. Inlined (`?react`) instead of loaded as URLs so the +// theme-adaptive badges (e.g. web) can follow `currentColor` — an external SVG +// referenced by has no inherited color and would render black. +import iconDefault from '~/assets/files/default.svg?react' +import iconPdf from '~/assets/files/pdf.svg?react' +import iconWord from '~/assets/files/word.svg?react' +import iconExcel from '~/assets/files/excel.svg?react' +import iconPpt from '~/assets/files/ppt.svg?react' +import iconZip from '~/assets/files/zip.svg?react' +import iconMarkdown from '~/assets/files/md.svg?react' +import iconJava from '~/assets/files/java.svg?react' +import iconJavascript from '~/assets/files/js.svg?react' +import iconPython from '~/assets/files/py.svg?react' +import iconText from '~/assets/files/txt.svg?react' +import iconMp3 from '~/assets/files/mp3.svg?react' +import iconWeb from '~/assets/files/web.svg?react' +import iconImage from '~/assets/icons/image.svg?react' +import iconAudio from '~/assets/icons/audio.svg?react' +import iconVideo from '~/assets/icons/video.svg?react' +import CloseIcon from '~/assets/icons/close.svg?react' +import RefreshIcon from '~/assets/icons/refresh.svg?react' +import SpinnerIcon from '~/assets/icons/generating.svg?react' + +/** Upload lifecycle of an attached file. Selection triggers an immediate + * upload to the project workspace; the composer blocks send until every file + * is 'done' and drops 'error' ones. */ +export type UploadStatus = 'uploading' | 'done' | 'error' + +export interface AttachedFile { + id: string + file: File + name: string + byte: number + type: 'file' | 'image' | 'audio' | 'video' + src?: string + /** Upload lifecycle; undefined is treated as 'done' (already-persisted). */ + status?: UploadStatus + /** Workspace-relative path returned by the upload (e.g. user_files/foo.png). */ + path?: string + /** Raw byte URL for preview / agent reference. */ + url?: string +} + +export function fileToAttached(file: File): AttachedFile { + const isImage = file.type.startsWith('image/') + const isAudio = file.type.startsWith('audio/') + const isVideo = file.type.startsWith('video/') + return { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + file, + name: file.name, + byte: file.size, + type: isImage ? 'image' : isAudio ? 'audio' : isVideo ? 'video' : 'file', + src: isImage || isAudio || isVideo ? URL.createObjectURL(file) : undefined, + status: 'uploading' + } +} + +// ---- Utils ---- + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` + return `${(bytes / (1024 * 1024)).toFixed(2)}MB` +} + +function getFileExt(name: string): string { + const parts = name.split('.') + return parts.length > 1 ? parts.pop()!.toUpperCase() : 'FILE' +} + +type FileIcon = React.FC> + +// File extension to icon mapping +const fileIcons: Record = { + // Documents + PDF: iconPdf, + DOC: iconWord, + DOCX: iconWord, + // Spreadsheets + XLS: iconExcel, + XLSX: iconExcel, + CSV: iconExcel, + // Presentations + PPT: iconPpt, + PPTX: iconPpt, + // Archives + ZIP: iconZip, + RAR: iconZip, + '7Z': iconZip, + TAR: iconZip, + GZ: iconZip, + // Code / Text + MD: iconMarkdown, + TXT: iconText, + LOG: iconText, + // Web sources + HTML: iconWeb, + HTM: iconWeb, + CSS: iconWeb, + // .ipynb has no dedicated badge asset; use the generic file badge so doc + // cards render uniformly (matching the composer upload card) instead of the + // odd line-art glyph. + IPYNB: iconDefault, + JS: iconJavascript, + TS: iconJavascript, + JSX: iconJavascript, + TSX: iconJavascript, + JAVA: iconJava, + PY: iconPython, + // Media + PNG: iconImage, + JPG: iconImage, + JPEG: iconImage, + GIF: iconImage, + SVG: iconImage, + WEBP: iconImage, + MP3: iconMp3, + WAV: iconAudio, + OGG: iconAudio, + FLAC: iconAudio, + MP4: iconVideo, + MOV: iconVideo, + AVI: iconVideo, + WEBM: iconVideo, + MKV: iconVideo +} + +function getFileIcon(ext: string): FileIcon { + return fileIcons[ext] ?? iconDefault +} + +/** File-type badge for a filename (extension-based, with fallback). The color + * class only affects badges drawn with `currentColor` (the neutral ones); the + * brand-colored plates carry their own fills. */ +export function FileTypeIcon({ + name, + className = '' +}: { + name: string + className?: string +}) { + const Icon = getFileIcon(getFileExt(name)) + return +} + +/** Format a byte count into a compact human string (e.g. 1.25MB). */ +export function formatFileSize(bytes: number): string { + return formatSize(bytes) +} + +// ---- Remove Button ---- + +function RemoveButton({ onClick }: { onClick?: () => void }) { + return ( + + ) +} + +// Card chrome for media (image/audio/video) shown in the message list, so they +// match the document card. It's only applied when the card is NOT removable: +// composer upload previews (removable) render bare, without this outer frame. +// `group-hover/filecard:*` reacts to the clickable wrapper in a chat bubble +// (UserBubble) and tints the card on hover. +const MEDIA_CARD = + 'rounded-xl border border-msa-line-2 bg-msa-fill-0 transition-colors group-hover/filecard:bg-msa-fill-4' + +// The native control (audio/video) and the antd image preview own their own +// clicks: stop the event so it doesn't bubble to the bubble wrapper's +// open-in-workspace handler. Clicking the surrounding card padding still opens. +const stopControl = { + onClick: (e: React.MouseEvent) => e.stopPropagation(), + onKeyDown: (e: React.KeyboardEvent) => e.stopPropagation() +} + +// ---- Image Card ---- + +function ImageCard({ + src, + name, + removable, + onRemove +}: { + src?: string + name: string + removable?: boolean + onRemove?: () => void +}) { + const thumb = ( +
+ {name} +
+ ) + return ( +
+ {removable && } + {removable ? ( + thumb + ) : ( +
{thumb}
+ )} +
+ ) +} + +// ---- Audio Card ---- + +function AudioCard({ + src, + removable, + onRemove +}: { + src?: string + removable?: boolean + onRemove?: () => void +}) { + return ( +
+ {removable && } +
+
+
+ ) +} + +// ---- Video Card ---- + +function VideoCard({ + src, + removable, + onRemove +}: { + src?: string + removable?: boolean + onRemove?: () => void +}) { + return ( +
+ {removable && } +
+
+
+ ) +} + +// ---- Document File Card ---- + +function DocCard({ + name, + byte, + note, + removable, + onRemove +}: { + name: string + byte?: number + /** Replaces the ext/size line with a note (e.g. "this file was deleted"). */ + note?: string + removable?: boolean + onRemove?: () => void +}) { + const ext = getFileExt(name) + + return ( +
+ {removable && } +
+ +
+ {name} + {note ? ( + {note} + ) : ( + + {ext} + {byte != null && ` ${formatSize(byte)}`} + + )} +
+
+
+ ) +} + +// ---- Main FileCard ---- + +interface FileCardProps { + name: string + byte?: number + type?: 'file' | 'image' | 'audio' | 'video' + src?: string + removable?: boolean + onRemove?: () => void + /** Upload lifecycle; when 'uploading'/'error' a status overlay is shown. */ + status?: UploadStatus + /** Retry handler; wired to the error overlay so a failed upload can re-run. */ + onRetry?: () => void + /** History replay: the workspace file is gone. Forces the generic doc card + * (no media preview) and shows `note` in place of the ext/size line. */ + deleted?: boolean + /** Sub-label under the name (e.g. the "file deleted" note). */ + note?: string +} + +/** Overlay covering a card while an upload is in flight or after it failed. */ +function StatusOverlay({ + status, + onRetry +}: { + status: UploadStatus + onRetry?: () => void +}) { + const { t } = useT() + if (status === 'uploading') { + return ( +
+ +
+ ) + } + return ( + + + + ) +} + +export function FileCard({ + name, + byte, + type = 'file', + src, + removable = false, + onRemove, + status, + onRetry, + deleted = false, + note +}: FileCardProps) { + const card = (() => { + // A deleted file has no bytes to preview — always fall back to the generic + // doc card, carrying the note (e.g. "this file was deleted"). + if (deleted) { + return ( + + ) + } + switch (type) { + case 'image': + return ( + + ) + case 'audio': + return + case 'video': + return + default: + return ( + + ) + } + })() + + if (!status || status === 'done') return card + return ( +
+ {card} + +
+ ) +} diff --git a/webui/frontend/app/components/common/FolderTree.css b/webui/frontend/app/components/common/FolderTree.css new file mode 100644 index 000000000..d95ab8cde --- /dev/null +++ b/webui/frontend/app/components/common/FolderTree.css @@ -0,0 +1,32 @@ +.folder-tree .ant-tree-treenode { + align-items: center; +} + +.folder-tree .ant-tree-node-content-wrapper { + display: flex !important; + align-items: center; + min-width: 0; + padding: 0; + overflow: hidden; +} + +.folder-tree .ant-tree-iconEle { + display: inline-flex !important; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 20px; + height: 20px; +} + +.folder-tree .ant-tree-title { + flex: 1 1 auto; + min-width: 0; + margin-left: 0; +} + +/* showIcon is off (icon rendered inside the title); drop the empty icon slot + so it doesn't reserve blank width before the in-title icon. */ +.folder-tree .ant-tree-iconEle:empty { + display: none !important; +} \ No newline at end of file diff --git a/webui/frontend/app/components/common/FolderTree.tsx b/webui/frontend/app/components/common/FolderTree.tsx new file mode 100644 index 000000000..f98199a78 --- /dev/null +++ b/webui/frontend/app/components/common/FolderTree.tsx @@ -0,0 +1,689 @@ +import { ConfigProvider, Dropdown, Tree } from 'antd' +import type { MenuProps, TreeDataNode, TreeProps } from 'antd' +import { + type FC, + type ReactNode, + type SVGProps, + useEffect, + useMemo, + useRef, + useState +} from 'react' +import { useT } from '~/lib/i18n' +import './FolderTree.css' + +// File type icons, inlined (`?react`) so the neutral ones can follow +// `currentColor` (see FileCard). +import iconDefault from '~/assets/files/default.svg?react' +import iconPdf from '~/assets/files/pdf.svg?react' +import iconWord from '~/assets/files/word.svg?react' +import iconExcel from '~/assets/files/excel.svg?react' +import iconPpt from '~/assets/files/ppt.svg?react' +import iconZip from '~/assets/files/zip.svg?react' +import iconMarkdown from '~/assets/files/md.svg?react' +import iconJava from '~/assets/files/java.svg?react' +import iconJavascript from '~/assets/files/js.svg?react' +import iconPython from '~/assets/files/py.svg?react' +import iconText from '~/assets/files/txt.svg?react' +import iconMp3 from '~/assets/files/mp3.svg?react' +import iconWeb from '~/assets/files/web.svg?react' +import iconFolder from '~/assets/icons/folder.svg?react' + +type FileIcon = FC> + +// Extension → icon mapping +const FILE_ICONS: Record = { + pdf: iconPdf, + doc: iconWord, + docx: iconWord, + xls: iconExcel, + xlsx: iconExcel, + csv: iconExcel, + ppt: iconPpt, + pptx: iconPpt, + zip: iconZip, + rar: iconZip, + '7z': iconZip, + tar: iconZip, + gz: iconZip, + md: iconMarkdown, + js: iconJavascript, + ts: iconJavascript, + jsx: iconJavascript, + tsx: iconJavascript, + java: iconJava, + py: iconPython, + json: iconJavascript, + mp3: iconMp3, + html: iconWeb, + htm: iconWeb, + css: iconWeb, + log: iconText, + txt: iconText, + yaml: iconDefault, + yml: iconDefault, + bin: iconDefault, + sh: iconDefault, + xml: iconDefault, + svg: iconDefault +} + +function iconFor(title: string, isDir: boolean): ReactNode { + const ext = title.split('.').pop()?.toLowerCase() ?? '' + const Icon = isDir ? iconFolder : FILE_ICONS[ext] ?? iconDefault + return +} + +// Node keys are `file:` / `dir:` (built by the caller). +function parseKey(key: string): { isDir: boolean; path: string } { + const isDir = key.startsWith('dir:') + return { isDir, path: key.slice(key.indexOf(':') + 1) } +} +const baseName = (p: string) => p.split('/').pop() ?? p +const parentDir = (p: string) => { + const i = p.lastIndexOf('/') + return i === -1 ? '' : p.slice(0, i) +} + +/** File-management actions surfaced by the right-click menu and drag & drop. + * The tree only reports intent (paths); the host performs the API calls, name + * prompts and confirmations. */ +export interface FolderTreeActions { + onNewFile: (dir: string) => void + onNewFolder: (dir: string) => void + /** Commit an inline rename: give `path` the new base name `newName`. */ + onRename: (path: string, newName: string) => void + onDelete: (path: string, isDir: boolean) => void + onCopyPath: (path: string) => void + onDownload: (path: string) => void + /** Move/rename `src` to `dest` (both workspace-relative). */ + onMove: (src: string, dest: string) => void + /** Native OS files dropped onto a folder node (`dir` '' = workspace root). */ + onUploadTo: (dir: string, files: FileList) => void + /** Batch delete a multi-selection. */ + onDeleteMany: (items: { path: string; isDir: boolean }[]) => void + /** Batch download (files only; folders are filtered out by the caller). */ + onDownloadMany: (paths: string[]) => void + /** Copy several workspace paths (newline-joined) to the clipboard. */ + onCopyPaths: (paths: string[]) => void + /** Batch move a multi-selection into a folder. */ + onMoveMany: (moves: { src: string; dest: string }[]) => void +} + +interface FolderTreeProps { + /** Tree structure data (string titles, `file:`/`dir:` keys). */ + treeData: TreeDataNode[] + /** Currently selected file key */ + selectedKey: string + /** Callback when a leaf node is selected */ + onSelect: (key: string) => void + /** Case-insensitive filter: non-matching files are hidden, matches highlighted. */ + filter?: string + /** File-management callbacks; when omitted the tree is read-only. */ + actions?: FolderTreeActions + /** Container className */ + className?: string + /** Expand every folder when the tree (re)loads. Default FALSE (repo-wide + * convention): the tree starts collapsed and only the ancestors of + * `selectedKey` auto-expand — deep-link style, revealing exactly the path + * being opened. Pass true to restore expand-all-on-load. */ + defaultExpandAll?: boolean +} + +// Collect keys of directory nodes (those with children), for expand-all. +function dirKeys(nodes: TreeDataNode[], acc: string[] = []): string[] { + for (const n of nodes) { + if (n.children) { + acc.push(String(n.key)) + dirKeys(n.children, acc) + } + } + return acc +} + +// A pruned copy of the tree keeping only files whose name matches `filter` +// (case-insensitive) and the directories on the way to them. Returns the kept +// nodes plus the dir keys that must be expanded to reveal the matches. +function filterTree( + nodes: TreeDataNode[], + q: string, + expand: string[] +): TreeDataNode[] { + const out: TreeDataNode[] = [] + for (const n of nodes) { + const title = String(n.title ?? '') + if (n.children) { + const kids = filterTree(n.children, q, expand) + const selfMatch = title.toLowerCase().includes(q) + if (kids.length > 0 || selfMatch) { + expand.push(String(n.key)) + out.push({ ...n, children: kids }) + } + } else if (title.toLowerCase().includes(q)) { + out.push(n) + } + } + return out +} + +function Highlight({ text, q }: { text: string; q: string }) { + if (!q) return <>{text} + const idx = text.toLowerCase().indexOf(q.toLowerCase()) + if (idx === -1) return <>{text} + return ( + <> + {text.slice(0, idx)} + + {text.slice(idx, idx + q.length)} + + {text.slice(idx + q.length)} + + ) +} + +// Inline rename editor rendered in place of a node's name. Autofocuses and +// pre-selects the base name (excluding the extension). Enter/blur commits, +// Escape cancels; a `done` guard prevents Escape's blur from also committing. +function RenameInput({ + initial, + onCommit, + onCancel +}: { + initial: string + onCommit: (value: string) => void + onCancel: () => void +}) { + const [value, setValue] = useState(initial) + const ref = useRef(null) + const done = useRef(false) + useEffect(() => { + const el = ref.current + if (!el) return + el.focus() + const dot = initial.lastIndexOf('.') + if (dot > 0) el.setSelectionRange(0, dot) + else el.select() + }, [initial]) + const commit = () => { + if (done.current) return + done.current = true + onCommit(value) + } + const cancel = () => { + if (done.current) return + done.current = true + onCancel() + } + return ( + setValue(e.target.value)} + onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onDoubleClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter') { + e.preventDefault() + commit() + } else if (e.key === 'Escape') { + e.preventDefault() + cancel() + } + }} + onBlur={commit} + className="mr-2 min-w-0 flex-1 rounded border border-msa-line-2 bg-msa-bg-1 px-1 text-sm text-msa-text-1 outline-none" + /> + ) +} + +/** + * A file-tree browser: full-row select/hover, a right-click context menu + * (new file/folder, rename, delete, copy path, download), drag-to-move between + * folders, native OS drag-and-drop upload onto folders, and a live name filter. + */ +export function FolderTree({ + treeData, + selectedKey, + onSelect, + filter = '', + actions, + className, + defaultExpandAll = false +}: FolderTreeProps) { + const { t } = useT() + const [expandedKeys, setExpandedKeys] = useState([]) + const [autoExpandParent, setAutoExpandParent] = useState(true) + // Folder key currently under a native file drag, for drop highlighting. + const [dropDir, setDropDir] = useState(null) + // Multi-selection (Ctrl/Cmd/Shift-click). The externally opened file + // (`selectedKey`) seeds it; plain single clicks open a file, modified clicks + // just grow the selection for batch operations. + const [selectedKeys, setSelectedKeys] = useState([]) + // After a context-menu item is clicked, antd closes the overlay and the + // click can “fall through” to the tree row underneath and select it. Ignore + // any select fired within a short window after a menu interaction. + const suppressSelectUntil = useRef(0) + // Anchor for Shift range selection (the last plain/toggle-clicked node). + const anchorKey = useRef(null) + // Key of the node being renamed inline (its name shows an ). + const [renamingKey, setRenamingKey] = useState(null) + + // Seed / reset the selection from the externally opened file. A plain click + // opens a file (updating `selectedKey`) and collapses the selection to it; + // modified clicks don't change `selectedKey`, so the multi-selection sticks. + useEffect(() => { + setSelectedKeys(selectedKey ? [selectedKey] : []) + anchorKey.current = selectedKey || null + }, [selectedKey]) + + const q = filter.trim().toLowerCase() + const allDirKeys = useMemo(() => dirKeys(treeData), [treeData]) + const dirSig = allDirKeys.join('|') + + const { data, matchExpand } = useMemo(() => { + if (!q) return { data: treeData, matchExpand: null as string[] | null } + const expand: string[] = [] + return { data: filterTree(treeData, q, expand), matchExpand: expand } + }, [treeData, q]) + + // Expand policy on (re)load: everything (default), or — when + // `defaultExpandAll` is off — only the ancestors of the selected file, so a + // deep link reveals exactly its own path. While filtering, expand only the + // ancestors of the matches so results are revealed. + useEffect(() => { + if (q && matchExpand) { + setExpandedKeys(matchExpand) + setAutoExpandParent(true) + } else if (defaultExpandAll) { + setExpandedKeys(allDirKeys) + setAutoExpandParent(false) + } else { + setExpandedKeys([]) + setAutoExpandParent(true) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [q, dirSig, defaultExpandAll]) + + // Collapsed-by-default mode: reveal the path of the externally opened file + // (merge its ancestor dirs into the expansion, keeping user-opened folders). + useEffect(() => { + if (defaultExpandAll || !selectedKey.startsWith('file:')) return + const path = selectedKey.slice('file:'.length) + const parts = path.split('/').slice(0, -1) + if (parts.length === 0) return + const ancestors: string[] = [] + for (let i = 1; i <= parts.length; i++) { + ancestors.push(`dir:${parts.slice(0, i).join('/')}`) + } + setExpandedKeys((prev) => [...new Set([...prev, ...ancestors])]) + setAutoExpandParent(true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedKey, defaultExpandAll, dirSig]) + + const menuItems = (isDir: boolean, path: string, key: string): MenuProps['items'] => { + if (!actions) return [] + // When right-clicking a node that's part of a multi-selection, offer batch + // operations over the whole selection instead of single-node actions. + if (selectedKeys.length > 1 && selectedKeys.includes(key)) { + const picked = selectedKeys.map(parseKey) + const files = picked.filter((p) => !p.isDir).map((p) => p.path) + const n = picked.length + const items: MenuProps['items'] = [] + if (files.length > 0) { + items.push({ + key: 'downloadMany', + label: `${t.workspace.download} (${files.length})`, + onClick: () => actions.onDownloadMany(files) + }) + } + items.push({ + key: 'copyMany', + label: `${t.workspace.copyPath} (${n})`, + onClick: () => actions.onCopyPaths(picked.map((p) => p.path)) + }) + items.push({ type: 'divider' }) + items.push({ + key: 'deleteMany', + label: `${t.workspace.delete} (${n})`, + danger: true, + onClick: () => actions.onDeleteMany(picked) + }) + return items + } + const items: MenuProps['items'] = [] + if (isDir) { + items.push({ + key: 'newFile', + label: t.workspace.newFile, + onClick: () => actions.onNewFile(path) + }) + items.push({ + key: 'newFolder', + label: t.workspace.newFolder, + onClick: () => actions.onNewFolder(path) + }) + items.push({ type: 'divider' }) + } else { + items.push({ + key: 'download', + label: t.workspace.download, + onClick: () => actions.onDownload(path) + }) + } + items.push({ + key: 'rename', + label: t.workspace.rename, + onClick: () => setRenamingKey(key) + }) + items.push({ + key: 'copy', + label: t.workspace.copyPath, + onClick: () => actions.onCopyPath(path) + }) + items.push({ type: 'divider' }) + items.push({ + key: 'delete', + label: t.workspace.delete, + danger: true, + onClick: () => actions.onDelete(path, isDir) + }) + return items + } + + // Native OS file drag handlers, attached per title. Gated on `types` carrying + // 'Files' so they never interfere with antd's internal node dragging. + const fileDragProps = (dir: string, key: string) => + actions + ? { + onDragOver: (e: React.DragEvent) => { + if (!e.dataTransfer.types.includes('Files')) return + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'copy' + if (dropDir !== key) setDropDir(key) + }, + onDragLeave: (e: React.DragEvent) => { + if (!e.dataTransfer.types.includes('Files')) return + setDropDir((k) => (k === key ? null : k)) + }, + onDrop: (e: React.DragEvent) => { + if (!e.dataTransfer.types.includes('Files')) return + e.preventDefault() + e.stopPropagation() + setDropDir(null) + if (e.dataTransfer.files.length > 0) + actions.onUploadTo(dir, e.dataTransfer.files) + } + } + : {} + + const styledData = useMemo(() => { + const decorate = (nodes: TreeDataNode[]): TreeDataNode[] => + nodes.map((node) => { + const key = String(node.key) + const { isDir, path } = parseKey(key) + const title = String(node.title ?? '') + const uploadDir = isDir ? path : parentDir(path) + const renaming = renamingKey === key + // The icon lives INSIDE the title (not antd's `showIcon` slot) and the + // title fills the row, so the context-menu trigger and native file-drop + // target cover the whole row — not just the file name text. + const titleEl = renaming ? ( + + {iconFor(title, isDir)} + { + setRenamingKey(null) + const next = v.trim() + if (next && next !== title) actions?.onRename(path, next) + }} + onCancel={() => setRenamingKey(null)} + /> + + ) : ( + { + if (!selectedKeys.includes(key)) setSelectedKeys([key]) + }} + {...fileDragProps(uploadDir, key)} + > + {iconFor(title, isDir)} + + + + + ) + const highlighted = renaming + ? '' // no selection highlight while editing the name inline + : selectedKeys.includes(key) + ? 'bg-msa-fill-4' + : dropDir === key + ? 'bg-msa-fill-4 ring-1 ring-inset ring-msa-line-2' + : 'hover:bg-msa-fill-4' + return { + ...node, + // While renaming, drop the context-menu wrapper so right-click and + // drag don't interfere with the input. + title: + actions && !renaming ? ( + { + domEvent?.stopPropagation?.() + suppressSelectUntil.current = Date.now() + 400 + } + }} + trigger={['contextMenu']} + > + {titleEl} + + ) : ( + titleEl + ), + className: `rounded-lg ${highlighted}`, + children: node.children ? decorate(node.children) : undefined + } + }) + return decorate(data) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, selectedKeys, dropDir, q, actions, renamingKey]) + + // Flat list of currently visible node keys in display order (a folder's + // children only count when it's expanded), so Shift-range selection matches + // exactly the rows the user sees. + const visibleKeys = useMemo(() => { + const expanded = new Set(expandedKeys) + const out: string[] = [] + const walk = (nodes: TreeDataNode[]) => { + for (const n of nodes) { + const k = String(n.key) + out.push(k) + if (n.children && expanded.has(k)) walk(n.children) + } + } + walk(data) + return out + }, [data, expandedKeys]) + + const rangeBetween = (a: string, b: string): string[] => { + const ai = visibleKeys.indexOf(a) + const bi = visibleKeys.indexOf(b) + if (ai === -1 || bi === -1) return [b] + const [lo, hi] = ai <= bi ? [ai, bi] : [bi, ai] + return visibleKeys.slice(lo, hi + 1) + } + + // Editor-style selection: plain click selects one (and opens a file); + // Ctrl/Cmd/Alt toggles a single row; Shift selects the contiguous range from + // the anchor (the last plain/toggle click) to the clicked row. + const handleTreeSelect: TreeProps['onSelect'] = (_keys, info) => { + if (Date.now() < suppressSelectUntil.current) { + suppressSelectUntil.current = 0 + return + } + const ne = info.nativeEvent as MouseEvent | undefined + const clicked = String(info.node.key) + const shift = !!ne && ne.shiftKey + const toggle = !!ne && (ne.ctrlKey || ne.metaKey || ne.altKey) + if (shift && anchorKey.current) { + // Range from anchor to clicked; anchor stays put for further shift-clicks. + setSelectedKeys(rangeBetween(anchorKey.current, clicked)) + return + } + if (toggle) { + setSelectedKeys((prev) => + prev.includes(clicked) + ? prev.filter((k) => k !== clicked) + : [...prev, clicked] + ) + anchorKey.current = clicked + return + } + // Plain click: collapse to just this node; a file opens, a FOLDER toggles + // its expansion (the whole row acts as the caret — no need to hit the tiny + // arrow). + setSelectedKeys([clicked]) + anchorKey.current = clicked + if (info.node.isLeaf) { + onSelect(clicked) + return + } + setExpandedKeys((prev) => + prev.includes(clicked) + ? prev.filter((k) => k !== clicked) + : [...prev, clicked] + ) + // Manual toggling must not be undone by antd's ancestor auto-expansion. + setAutoExpandParent(false) + } + + // Replace the browser's default drag ghost (a loose snapshot of the whole + // tree row, with stray padding/whitespace) with a compact icon+name pill. + const onDragStart: TreeProps['onDragStart'] = (info) => { + const dt = info.event.dataTransfer + const row = (info.event.target as HTMLElement | null)?.closest?.( + '.ant-tree-treenode' + ) as HTMLElement | null + if (!dt || !dt.setDragImage || !row) return + const iconEl = row.querySelector('.ant-tree-title img') as HTMLImageElement | null + const name = row.querySelector('.ant-tree-title')?.textContent ?? '' + // Dragging any node of a multi-selection moves the whole set: show a count. + const dragKey = String(info.node.key) + const multi = selectedKeys.length > 1 && selectedKeys.includes(dragKey) + const ghost = document.createElement('div') + ghost.style.cssText = + 'position:fixed;top:-1000px;left:-1000px;display:inline-flex;align-items:center;gap:8px;max-width:260px;padding:4px 10px;border-radius:8px;background:var(--msa-bg-1);border:1px solid var(--msa-line-2);box-shadow:var(--msa-shadow-s);font-size:13px;line-height:20px;color:var(--msa-text-1);white-space:nowrap;overflow:hidden' + if (!multi && iconEl) { + const i = iconEl.cloneNode(true) as HTMLImageElement + i.style.cssText = 'width:14px;height:14px;flex:0 0 auto' + ghost.appendChild(i) + } + const label = document.createElement('span') + label.textContent = multi + ? `${selectedKeys.length} ${t.workspace.selectedItems}` + : name + label.style.cssText = 'overflow:hidden;text-overflow:ellipsis' + ghost.appendChild(label) + document.body.appendChild(ghost) + dt.setDragImage(ghost, 12, 16) + // Remove once the browser has snapshotted it for the drag image. + setTimeout(() => ghost.remove(), 0) + } + + // Internal drag-to-move: drop onto a folder moves into it; drop onto/next to + // a file targets that file's parent dir. Dragging a node of a multi-selection + // moves the whole set. Guards against no-ops and moving a folder into its own + // subtree. + const onDrop: TreeProps['onDrop'] = (info) => { + if (!actions) return + const target = parseKey(String(info.node.key)) + const destDir = + !info.dropToGap && target.isDir ? target.path : parentDir(target.path) + const dragKey = String(info.dragNode.key) + const sources = + selectedKeys.length > 1 && selectedKeys.includes(dragKey) + ? selectedKeys.map(parseKey) + : [parseKey(dragKey)] + const moves: { src: string; dest: string }[] = [] + for (const { path: src, isDir: srcIsDir } of sources) { + const dest = destDir ? `${destDir}/${baseName(src)}` : baseName(src) + if (dest === src) continue + if (srcIsDir && dest.startsWith(`${src}/`)) continue + moves.push({ src, dest }) + } + if (moves.length === 0) return + if (moves.length === 1) actions.onMove(moves[0].src, moves[0].dest) + else actions.onMoveMany(moves) + } + + return ( + + {q && styledData.length === 0 ? ( +
+ {t.workspace.noSearchResults} +
+ ) : ( +
{ + if (!actions || renamingKey || selectedKeys.length !== 1) return + if (e.key !== 'F2' && e.key !== 'Enter') return + const el = e.target as HTMLElement | null + if ( + el && + (el.tagName === 'INPUT' || + el.tagName === 'TEXTAREA' || + el.isContentEditable) + ) + return + e.preventDefault() + e.stopPropagation() + setRenamingKey(selectedKeys[0]) + }} + > + { + setExpandedKeys(keys.map(String)) + setAutoExpandParent(false) + }} + onDrop={onDrop} + onDragStart={actions ? onDragStart : undefined} + className={className} + rootClassName="folder-tree" + classNames={{ + itemSwitcher: 'before:hidden' + }} + onSelect={handleTreeSelect} + /> +
+ )} +
+ ) +} diff --git a/webui/frontend/app/components/common/IconButton.tsx b/webui/frontend/app/components/common/IconButton.tsx new file mode 100644 index 000000000..db8c30dc7 --- /dev/null +++ b/webui/frontend/app/components/common/IconButton.tsx @@ -0,0 +1,71 @@ +import { forwardRef } from 'react' +import { MsaButton } from './MsaButton' +import type { MsaButtonProps } from './MsaButton' + +/* ================================================================ + * IconButton — Square icon-only button + * + * Based on MsaButton with preset square layout: + * - Centered icon + * - Configurable size (default 32px) + * - Rounded-xl border-radius + * + * Usage (icons come from app/assets/icons via `?react`, sized by class): + * } /> + * } variant="primary" size="sm" /> + * ================================================================ */ + +interface IconButtonProps extends Omit { + /** + * Predefined sizes: + * - `xs` 20px (sidebar actions) + * - `sm` 28px (compact) + * - `md` 32px (default) + * - `lg` 40px + */ + size?: 'xs' | 'sm' | 'md' | 'lg' + /** Stop click event from bubbling to parent elements. Default: true */ + stopPropagation?: boolean +} + +const sizeStyles: Record = { + xs: 'h-5 w-5 min-w-0 rounded-md text-xs', + sm: 'h-7 w-7 min-w-0 rounded-lg text-xs', + md: 'h-8 w-8 min-w-0 rounded-xl text-sm', + lg: 'h-10 w-10 min-w-0 rounded-xl text-base' +} + +export const IconButton = forwardRef( + ( + { + size = 'md', + variant = 'ghost', + stopPropagation = true, + className = '', + onClick, + ...rest + }, + ref + ) => { + const noHoverBg = variant === 'ghost' ? 'hover:bg-transparent' : '' + // Icon centering is handled by the base MsaButton; here we only add the + // square layout + size preset. Any caller `classNames` flows via `...rest`. + return ( + { + if (stopPropagation) { + e.stopPropagation() + e.preventDefault() + } + onClick?.(e) + }} + {...rest} + /> + ) + } +) + +IconButton.displayName = 'IconButton' diff --git a/webui/frontend/app/components/common/Markdown.css b/webui/frontend/app/components/common/Markdown.css new file mode 100644 index 000000000..33f767d47 --- /dev/null +++ b/webui/frontend/app/components/common/Markdown.css @@ -0,0 +1,27 @@ +.msa-md-body { + --light-bg: var(--msa-fill-1); + --dark-bg: var(--msa-fill-1); +} + +.msa-md-body .msa-code-highlighter pre code { + line-height: 1.6; + overflow-x: auto; +} + +.msa-md-body pre { + background: var(--light-bg) !important; +} + +/* ---- Inline code ---------------------------------------------------------- + * Subtle chip on the msa fill token instead of the theme's gray + heavy + * border; slightly smaller so it doesn't crowd the prose line. */ +.msa-md-body pre:not(.msa-code-highlighter pre) { + border-radius: 6px !important; +} + +.msa-md-body pre code:not(.msa-code-highlighter pre code) { + background: var(--msa-fill-2) !important; + color: var(--msa-text-2) !important; + padding: 2px 10px !important; + font-size: 0.9em !important; +} \ No newline at end of file diff --git a/webui/frontend/app/components/common/Markdown.tsx b/webui/frontend/app/components/common/Markdown.tsx new file mode 100644 index 000000000..12a064007 --- /dev/null +++ b/webui/frontend/app/components/common/Markdown.tsx @@ -0,0 +1,139 @@ +import { CodeHighlighter, Mermaid } from '@ant-design/x' +import { XMarkdown } from '@ant-design/x-markdown' +import type { ComponentProps } from '@ant-design/x-markdown' +import Latex from '@ant-design/x-markdown/plugins/Latex' +import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism' +import { useTheme } from '~/lib/theme' +import './Markdown.css' +// Typography themes (x-markdown-light / x-markdown-dark) are @imported in +// app.css — importing the package css here would crash Node SSR (deep css +// imports of an externalized package bypass Vite's pipeline). + +interface Props { + content: string + /** Pass true while the content is still being streamed in. */ + streaming?: boolean + /** Handle a leading YAML frontmatter block (```---…---```). CommonMark has + * no frontmatter concept (and x-markdown ships no extension for it), so the + * raw block would render as a broken heading/paragraph mix. When enabled, + * the block is re-emitted as a fenced ```yaml code block instead. */ + frontmatter?: boolean +} + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/ + +function withFrontmatterAsYaml(src: string): string { + const m = FRONTMATTER_RE.exec(src) + if (!m) return src + return '```yaml\n' + m[1] + '\n```\n\n' + src.slice(m[0].length) +} + +/** Flatten the ReactNode children of a mapped tag into plain text (fenced + * code bodies arrive as text nodes / arrays of text nodes). */ +function textOf(children: React.ReactNode): string { + if (typeof children === 'string') return children + if (Array.isArray(children)) return children.map(textOf).join('') + return children == null ? '' : String(children) +} + +/** CodeHighlighter hardcodes the prism `oneLight` palette and ignores antd's + * darkAlgorithm — in dark mode inject `oneDark` via `highlightProps` (kept + * transparent so the card's own background wins). */ +function useHighlightProps() { + const { theme } = useTheme() + if (theme !== 'dark') return undefined + return { + style: { + ...oneDark, + 'pre[class*="language-"]': { + ...oneDark['pre[class*="language-"]'], + background: 'transparent', + margin: 0 + }, + 'code[class*="language-"]': { + ...oneDark['code[class*="language-"]'], + background: 'transparent' + } + } + } +} + +/** Fenced code blocks → CodeHighlighter (language pill + copy button); + * ```mermaid fences → live Mermaid diagrams; inline code stays plain. */ +function Code(props: ComponentProps) { + const highlightProps = useHighlightProps() + const className = String((props as { className?: string }).className ?? '') + const lang = /language-(\w+)/.exec(className)?.[1] + const body = textOf(props.children) + + if (!lang) { + // Inline code (no language- class): keep the default element. + return {props.children} + } + if (lang === 'mermaid') { + // Render the diagram only once the fence is complete; while streaming, + // show the source as a code block (avoids mermaid parse churn). + if (props.streamStatus === 'loading') { + return ( + + {body} + + ) + } + return {body} + } + return ( + + {body} + + ) +} + +/** `` tag emitted by x-markdown for mermaid fences → diagram. */ +function MermaidTag(props: ComponentProps) { + return {textOf(props.children)} +} + +// Stable references (x-markdown best practice: never rebuild per render). +const COMPONENTS = { + code: Code, + mermaid: MermaidTag +} +const CONFIG = { extensions: Latex() } + +/** + * Project-wide Markdown renderer. Wraps `@ant-design/x-markdown` so chat + * messages, skill viewers, and any other consumers share a single import path + * — making future swaps (theme tokens, plugins, custom components) one-edit + * changes. + * + * Bundled capabilities (chat-oriented): + * - GFM basics (tables, lists, links…) from x-markdown itself; + * - fenced code → CodeHighlighter, ```mermaid → Mermaid diagrams; + * - LaTeX math ($…$ / $$…$$) via the official Latex plugin (KaTeX); + * - optional YAML frontmatter handling (`frontmatter` prop); + * - light/dark typography theme following the app theme. + */ +export function Markdown({ content, streaming, frontmatter }: Props) { + const { theme } = useTheme() + return ( + + ) +} diff --git a/webui/frontend/app/components/common/McpSelector.tsx b/webui/frontend/app/components/common/McpSelector.tsx new file mode 100644 index 000000000..de83212e0 --- /dev/null +++ b/webui/frontend/app/components/common/McpSelector.tsx @@ -0,0 +1,96 @@ +import { Popover } from 'antd' +import { useMemo, useState } from 'react' +import { useNavigate } from 'react-router' +import { useT } from '~/lib/i18n' +import type { Mcp, Project } from '~/lib/types' +import { PillButton } from './PillButton' +import McpSelectIcon from '~/assets/icons/mcp-select.svg?react' + +interface McpSelectorProps { + items: Mcp[] + project?: Project | null +} + +/** + * Read-only view of the MCP services active for this chat. Enablement lives + * ONLY in project settings and global settings — the composer has no per-chat + * toggles; it lists what those settings resolved to and links to the right + * settings page (project tab when a project is selected, else global). + */ +export function McpSelector({ items, project }: McpSelectorProps) { + const { t } = useT() + const navigate = useNavigate() + const [open, setOpen] = useState(false) + + // Only MCPs enabled in global/project settings apply to the chat. + const enabledItems = useMemo(() => items.filter((m) => m.enabled), [items]) + + const settingsPath = project + ? `/projects/${project.id}?tab=mcps` + : '/settings/mcp-skills?tab=mcps' + + const content = ( +
+ {/* Header: title only — enablement is settings-driven */} +
+ + {t.home.mcpPopoverTitle} + +
+ +
+ + {/* List */} +
+ {enabledItems.length ? ( + enabledItems.map((it) => ( +
+ + {it.name} + +
+ )) + ) : ( +
+ )} +
+ +
+ + {/* Footer: settings link */} +
+ { + setOpen(false) + navigate(settingsPath) + }} + > + {t.home.mcpSettings} + +
+
+ ) + + return ( + + + } + > + {enabledItems.length} {t.home.mcpPill} + + + ) +} diff --git a/webui/frontend/app/components/common/ModelSelector.css b/webui/frontend/app/components/common/ModelSelector.css new file mode 100644 index 000000000..831775cdf --- /dev/null +++ b/webui/frontend/app/components/common/ModelSelector.css @@ -0,0 +1,10 @@ +/* ModelSelector.css + * 当某个 model item 被 hover 或选中时,隐藏其上/下相邻的分割线, + * 使高亮块呈现为独立的圆角卡片。分割线仍占位(opacity 过渡),布局不跳动。 + */ +.msel-list button:hover+.msel-divider, +.msel-list .msel-selected+.msel-divider, +.msel-list .msel-divider:has(+ button:hover), +.msel-list .msel-divider:has(+ .msel-selected) { + opacity: 0; +} \ No newline at end of file diff --git a/webui/frontend/app/components/common/ModelSelector.tsx b/webui/frontend/app/components/common/ModelSelector.tsx new file mode 100644 index 000000000..856627259 --- /dev/null +++ b/webui/frontend/app/components/common/ModelSelector.tsx @@ -0,0 +1,166 @@ +import { CheckOutlined } from '@ant-design/icons' +import { Popover } from 'antd' +import { Fragment, useMemo, useState } from 'react' +import { useT } from '~/lib/i18n' +import type { AgentSettings, Model, Provider } from '~/lib/types' +import { PillButton } from './PillButton' +import { EmptyState } from './EmptyState' +import { DeferredSkeleton } from './DeferredSkeleton' +import './ModelSelector.css' +import JumpIcon from '~/assets/icons/jump.svg?react' + +interface ModelSelectorProps { + /** null while the lists are still loading — the panel then shows a + * skeleton instead of an "empty" state that isn't true yet. */ + models: Model[] | null + providers: Provider[] | null + settings: AgentSettings | null + onSelectModel: (providerId: string, modelId: string) => void +} + +export function ModelSelector({ + models, + providers, + settings, + onSelectModel +}: ModelSelectorProps) { + const { t } = useT() + const [open, setOpen] = useState(false) + const [activeProviderId, setActiveProviderId] = useState(null) + + const defaultModel = useMemo( + () => models?.find((m) => m.id === settings?.default_model_id) ?? null, + [models, settings] + ) + + // Sync active provider with the current default provider when opening. + const effectiveProviderId = + activeProviderId ?? + defaultModel?.provider_id ?? + settings?.default_provider_id ?? + providers?.[0]?.id ?? + null + + const activeProvider = useMemo( + () => providers?.find((p) => p.id === effectiveProviderId) ?? null, + [providers, effectiveProviderId] + ) + + const providerModels = useMemo( + () => + (models ?? []) + .filter((m) => m.provider_id === effectiveProviderId) + .sort((a, b) => + (a.display_name || a.name).localeCompare(b.display_name || b.name) + ), + [models, effectiveProviderId] + ) + + const handleSelectModel = (model: Model) => { + onSelectModel(model.provider_id, model.id) + setOpen(false) + } + + return ( + + {/* Left: providers */} +
+ {(providers ?? []).map((p) => { + const selected = p.id === effectiveProviderId + return ( + + ) + })} +
+ + {/* Right: models */} +
+ {models === null || providers === null ? ( + + ) : activeProvider ? ( + providerModels.length === 0 ? ( +
+ +
+ ) : ( +
+ {providerModels.map((m, idx) => { + const selected = m.id === settings?.default_model_id + return ( + + {idx > 0 && ( +
+ )} + + + ) + })} +
+ ) + ) : ( +
+ — +
+ )} +
+
+ } + > + + } + > + {defaultModel?.display_name ?? t.home.modelPill} + +
+ ) +} diff --git a/webui/frontend/app/components/common/MsaButton.tsx b/webui/frontend/app/components/common/MsaButton.tsx new file mode 100644 index 000000000..5d92b6385 --- /dev/null +++ b/webui/frontend/app/components/common/MsaButton.tsx @@ -0,0 +1,64 @@ +import { Button } from 'antd' +import type { ButtonProps } from 'antd' +import { forwardRef } from 'react' + +/* ================================================================ + * MsaButton — Base button + * + * Wraps antd Button with: + * 1. Removed default border / shadow + * 2. Five color variants (primary / filled / tonal / outlined / ghost) + * 3. antd click ripple effect + * + * Size, radius, spacing are all controlled via external className. + * ================================================================ */ + +export interface MsaButtonProps extends Omit { + /** + * Color variant: + * - `primary` Deep purple background (purple-10) + white text + * - `filled` White/surface background (text-0) + dark text, hover fill-3 + * - `tonal` Gray fill (fill-2) + dark text (default) + * - `outlined` Transparent background + border + dark text + * - `ghost` Transparent background + secondary text color + */ + variant?: 'primary' | 'filled' | 'tonal' | 'outlined' | 'ghost' +} + +const variantStyles: Record = { + primary: + 'bg-msa-purple-10 text-white disabled:cursor-not-allowed disabled:opacity-40', + filled: + 'bg-msa-fill-0 text-msa-text-1 hover:bg-msa-fill-3 disabled:cursor-not-allowed disabled:text-msa-text-disabled disabled:hover:bg-msa-text-0', + tonal: + 'bg-msa-fill-2 text-msa-text-1 hover:bg-msa-fill-3 disabled:cursor-not-allowed disabled:text-msa-text-disabled disabled:hover:bg-msa-fill-2', + outlined: + 'bg-msa-fill-0 !border !border-solid !border-msa-line-1 text-msa-text-1 hover:bg-msa-fill-2 disabled:cursor-not-allowed disabled:text-msa-text-disabled', + ghost: + 'bg-transparent text-msa-text-2 hover:bg-msa-fill-2 disabled:cursor-not-allowed disabled:text-msa-text-disabled disabled:hover:bg-transparent' +} + +export const MsaButton = forwardRef( + ({ variant = 'tonal', className = '', classNames, ...rest }, ref) => { + const extraClassNames = + classNames && typeof classNames === 'object' + ? (classNames as Record) + : {} + const extraIcon = + typeof extraClassNames.icon === 'string' ? extraClassNames.icon : '' + return ( + + ) +} diff --git a/webui/frontend/app/components/common/MsaTextArea.tsx b/webui/frontend/app/components/common/MsaTextArea.tsx new file mode 100644 index 000000000..42f7f4b88 --- /dev/null +++ b/webui/frontend/app/components/common/MsaTextArea.tsx @@ -0,0 +1,74 @@ +import { Input } from 'antd' +import type { TextAreaProps } from 'antd/es/input' +import type { CSSProperties } from 'react' +import { useEffect, useState } from 'react' + +const LINE_HEIGHT = 22 +const PADDING_VERTICAL = 10 // paddingTop 4 + paddingBottom 4 + borderTop 1 + borderBottom 1 + +interface MsaTextAreaClassNames { + root?: string + textarea?: string + clear?: string + count?: string +} + +interface MsaTextAreaStyles { + root?: CSSProperties + textarea?: CSSProperties + clear?: CSSProperties + count?: CSSProperties +} + +interface MsaTextAreaProps extends Omit< + TextAreaProps, + 'classNames' | 'styles' +> { + classNames?: MsaTextAreaClassNames + styles?: MsaTextAreaStyles +} + +/** + * Wrapper around antd Input.TextArea that prevents SSR hydration height flash. + * + * During SSR (before client mount), autoSize is disabled and a fixed height is + * calculated from minRows (rows × lineHeight + padding). After mount, autoSize + * takes over normally. + */ +export function MsaTextArea({ + autoSize, + classNames: classNamesProp, + styles: stylesProp, + ...rest +}: MsaTextAreaProps) { + const [mounted, setMounted] = useState(false) + useEffect(() => { + setMounted(true) + }, []) + + // Compute fixed height for SSR phase: rows * lineHeight + padding + const minRows = + typeof autoSize === 'object' ? (autoSize.minRows ?? 2) : undefined + const ssrHeight = minRows + ? minRows * LINE_HEIGHT + PADDING_VERTICAL + : undefined + + return ( + + ) +} diff --git a/webui/frontend/app/components/common/NProgressHandler.css b/webui/frontend/app/components/common/NProgressHandler.css new file mode 100644 index 000000000..afa8c4d0e --- /dev/null +++ b/webui/frontend/app/components/common/NProgressHandler.css @@ -0,0 +1,32 @@ +/* NProgress top loading bar — themed with MSA design tokens. + * We deliberately do NOT import nprogress/nprogress.css so the default blue + * (#29d) never applies; all visual styling is defined here via CSS variables. + * NProgress injects a #nprogress container with .bar > .peg into . */ + +#nprogress { + pointer-events: none; +} + +#nprogress .bar { + position: fixed; + top: 0; + left: 0; + z-index: 3000; + width: 100%; + height: 2px; + background: var(--msa-purple-4); +} + +/* The little glowing comet at the leading edge of the bar. */ +#nprogress .peg { + display: block; + position: absolute; + right: 0; + width: 100px; + height: 100%; + opacity: 1; + transform: rotate(3deg) translate(0, -4px); + box-shadow: + 0 0 10px var(--msa-purple-4), + 0 0 5px var(--msa-purple-4); +} diff --git a/webui/frontend/app/components/common/NProgressHandler.tsx b/webui/frontend/app/components/common/NProgressHandler.tsx new file mode 100644 index 000000000..75dcbd622 --- /dev/null +++ b/webui/frontend/app/components/common/NProgressHandler.tsx @@ -0,0 +1,32 @@ +import { useEffect } from 'react' +import { useNavigation } from 'react-router' +import nprogress from 'nprogress' + +import './NProgressHandler.css' + +nprogress.configure({ showSpinner: false, trickleSpeed: 120 }) + +/** + * Top loading bar driven by React Router navigation. While a client page + * transition is pending (`navigation.location` is set) NProgress runs; it + * completes when the new route commits. Styling lives in the co-located + * NProgressHandler.css (design-token colors, no default NProgress blue). + * Initial full-document loads aren't navigations, so the bar only appears on + * in-app page switches. Start is DELAYED 150ms so a fast transition never + * flashes the bar (nprogress.done() is a no-op when it never started). + */ +export function NProgressHandler() { + const navigation = useNavigation() + const isNavigating = Boolean(navigation.location) + + useEffect(() => { + if (!isNavigating) return + const t = setTimeout(() => nprogress.start(), 150) + return () => { + clearTimeout(t) + nprogress.done() + } + }, [isNavigating]) + + return null +} diff --git a/webui/frontend/app/components/common/PillButton.tsx b/webui/frontend/app/components/common/PillButton.tsx new file mode 100644 index 000000000..8f0491d89 --- /dev/null +++ b/webui/frontend/app/components/common/PillButton.tsx @@ -0,0 +1,89 @@ +import { Tooltip } from 'antd' +import { forwardRef, useEffect, useRef, useState } from 'react' +import { MsaButton } from './MsaButton' +import type { MsaButtonProps } from './MsaButton' +import ArrowDownIcon from '~/assets/icons/arrow-down.svg?react' + +/* ================================================================ + * PillButton — Pill-shaped selector button (e.g. Model / MCP pills) + * + * rounded-full + icon + optional dropdown caret + * + * The label is width-capped and truncates with an ellipsis so a long + * name (e.g. a full model id) stays compact on narrow layouts instead + * of blowing out the composer row. The cap is container-relative (cqw, + * resolved against the composer's @container) rather than viewport- + * relative, so it still holds when the composer column is narrow but the + * viewport is wide (e.g. a detail rail is open). A tooltip surfaces the + * full text — but only when the label is actually clipped. + * ================================================================ */ + +interface PillButtonProps extends Omit { + /** Whether to show dropdown arrow (default true) */ + caret?: boolean + /** Panel open state — flips the caret (same 180° + transition as the + * accordion headers) so the pill reads as expanded. */ + open?: boolean +} + +export const PillButton = forwardRef( + ( + { + caret = true, + open = false, + children, + className = '', + classNames, + ...rest + }, + ref + ) => { + const labelRef = useRef(null) + const [clipped, setClipped] = useState(false) + const [labelText, setLabelText] = useState('') + useEffect(() => { + const el = labelRef.current + if (!el) return + const measure = () => { + setClipped(el.scrollWidth > el.clientWidth + 1) + setLabelText(el.textContent ?? '') + } + measure() + const ro = new ResizeObserver(measure) + ro.observe(el) + return () => ro.disconnect() + }, [children]) + + const extra = + classNames && typeof classNames === 'object' + ? (classNames as Record) + : {} + return ( + + {/* Tooltip only engages when the label is clipped (empty title = no + tooltip); it wraps the inner span, not the button, so it never + conflicts with the selector Popover that triggers on the button. */} + + + {children} + + + {caret && ( + + )} + + ) + } +) + +PillButton.displayName = 'PillButton' diff --git a/webui/frontend/app/components/common/SkillSelector.tsx b/webui/frontend/app/components/common/SkillSelector.tsx new file mode 100644 index 000000000..7b1dae1d1 --- /dev/null +++ b/webui/frontend/app/components/common/SkillSelector.tsx @@ -0,0 +1,95 @@ +import { Popover } from 'antd' +import { useMemo, useState } from 'react' +import { useNavigate } from 'react-router' +import { useT } from '~/lib/i18n' +import type { Project, Skill } from '~/lib/types' +import { PillButton } from './PillButton' + +interface SkillSelectorProps { + items: Skill[] + project?: Project | null +} + +/** + * Read-only view of the skills active for this chat. Enablement lives ONLY in + * project settings and global settings — the composer has no per-chat toggles; + * it lists what those settings resolved to and links to the right settings + * page (project tab when a project is selected, else global). + */ +export function SkillSelector({ items, project }: SkillSelectorProps) { + const { t } = useT() + const navigate = useNavigate() + const [open, setOpen] = useState(false) + + // Only skills enabled in global/project settings apply to the chat. + const enabledItems = useMemo(() => items.filter((s) => s.enabled), [items]) + + const settingsPath = project + ? `/projects/${project.id}?tab=skills` + : '/settings/mcp-skills?tab=skills' + + const content = ( +
+ {/* Header: title only — enablement is settings-driven */} +
+ + {t.home.skillPopoverTitle} + +
+ +
+ + {/* List */} +
+ {enabledItems.length ? ( + enabledItems.map((it) => ( +
+ + {it.name} + +
+ )) + ) : ( +
+ )} +
+ +
+ + {/* Footer: settings link */} +
+ { + setOpen(false) + navigate(settingsPath) + }} + > + {t.home.skillSettings} + +
+
+ ) + + return ( + + + } + > + {enabledItems.length} {t.home.skillPill} + + + ) +} diff --git a/webui/frontend/app/components/common/StableSender.tsx b/webui/frontend/app/components/common/StableSender.tsx new file mode 100644 index 000000000..487a5b9b8 --- /dev/null +++ b/webui/frontend/app/components/common/StableSender.tsx @@ -0,0 +1,52 @@ +import { Sender } from '@ant-design/x' +import type { SenderProps } from '@ant-design/x/es/sender' +import { useMemo } from 'react' + +const LINE_HEIGHT = 22 +const PADDING_BLOCK = 5 + +/** Imperative handle exposed by Sender (focus/insert/getValue…). */ +export type SenderHandle = React.ComponentRef + +type StableSenderProps = SenderProps & { + /** Imperative Sender handle (insert/focus/getValue) — needed in slot mode + * where `value` is uncontrolled. React 19: ref is a regular prop. */ + ref?: React.Ref> +} + +export function StableSender(props: StableSenderProps) { + const { autoSize, classNames, styles, ref, ...rest } = props + + const minRows = typeof autoSize === 'object' ? (autoSize.minRows ?? 1) : 1 + + const mergedClassNames = useMemo( + () => ({ + ...classNames, + input: `resize-none ${classNames?.input ?? ''}` + }), + [classNames] + ) + + const mergedStyles = useMemo( + () => ({ + ...styles, + input: { + minHeight: LINE_HEIGHT * minRows + PADDING_BLOCK * 2, + ...styles?.input + } + }), + [styles, minRows] + ) + + return ( + + ) +} + +StableSender.Header = Sender.Header diff --git a/webui/frontend/app/components/layout/Sidebar.tsx b/webui/frontend/app/components/layout/Sidebar.tsx new file mode 100644 index 000000000..8fd57d4f0 --- /dev/null +++ b/webui/frontend/app/components/layout/Sidebar.tsx @@ -0,0 +1,760 @@ +import { App, Dropdown, Input, Modal, Popover, Tooltip } from 'antd' +import type { MenuProps } from 'antd' +import { IconButton } from '~/components/common/IconButton' +import { useEffect, useMemo, useState } from 'react' +import logoImg from '~/assets/images/logo.png' +import { + NavLink, + useLocation, + useNavigate, + useRevalidator, + useRouteLoaderData +} from 'react-router' +import { MsaButton } from '~/components/common/MsaButton' +import { NewProjectModal } from '~/components/project/NewProjectModal' +import { api } from '~/lib/api' +import { useT } from '~/lib/i18n' +import { usePresence } from '~/lib/presenceContext' +import { useUrlPath } from '~/lib/useUrlPath' +import type { Project, Session } from '~/lib/types' +import SidebarToggleIcon from '~/assets/icons/sidebar-toggle.svg?react' +import McpIcon from '~/assets/icons/mcp.svg?react' +import SkillIcon from '~/assets/icons/skill.svg?react' +import SettingsIcon from '~/assets/icons/settings.svg?react' +import NewChatIcon from '~/assets/icons/new-chat.svg?react' +import MoreChatsIcon from '~/assets/icons/more-chats.svg?react' +import AddIcon from '~/assets/icons/add.svg?react' +import NewProjectIcon from '~/assets/icons/new-project.svg?react' +import MoreIcon from '~/assets/icons/more.svg?react' +import ExpandIcon from '~/assets/icons/expand.svg?react' +import SpinnerIcon from '~/assets/icons/generating.svg?react' + +interface AppLoaderData { + projects: Project[] + sessions: Session[] +} + +const DEFAULT_PROJECT_ID = 'default' + +interface SidebarProps { + collapsed?: boolean + onCollapse?: () => void + onExpand?: () => void + onNavigate?: () => void +} + +export function Sidebar({ + collapsed = false, + onCollapse, + onExpand, + onNavigate +}: SidebarProps) { + const { t } = useT() + const navigate = useNavigate() + const revalidator = useRevalidator() + const data = useRouteLoaderData('layouts/app') as AppLoaderData | undefined + const projects = data?.projects ?? [] + const sessions = data?.sessions ?? [] + + // Project modal state + const [projectModalOpen, setProjectModalOpen] = useState(false) + const [editingProject, setEditingProject] = useState(null) + + const openCreateProject = () => { + setEditingProject(null) + setProjectModalOpen(true) + } + + const openEditProject = (p: Project) => { + setEditingProject(p) + setProjectModalOpen(true) + } + + const sessionsByProject = useMemo(() => { + const map = new Map() + for (const s of sessions) { + const pid = s.project_id ?? DEFAULT_PROJECT_ID + const list = map.get(pid) + if (list) list.push(s) + else map.set(pid, [s]) + } + return map + }, [sessions]) + + const orderedProjects = useMemo(() => { + const def = projects.filter((p) => p.is_default) + const named = projects.filter((p) => !p.is_default) + return [...def, ...named] + }, [projects]) + + const openNewChat = () => { + navigate('/') + onNavigate?.() + } + + return ( + <> + + + {/* Project create/edit modal */} + setProjectModalOpen(false)} + onCreated={(p) => { + setProjectModalOpen(false) + revalidator.revalidate() + navigate(`/projects/${p.id}`) + }} + onUpdated={() => { + setProjectModalOpen(false) + revalidator.revalidate() + }} + /> + + ) +} + +function SidebarNavItem({ + to, + label, + icon, + onNavigate, + className +}: { + to: string + label: string + icon: React.ReactNode + onNavigate?: () => void + className?: string +}) { + return ( + + {icon} + {label} + + ) +} + +function CollapsedProjectList({ + projects, + sessionsByProject, + onNavigate +}: { + projects: Project[] + sessionsByProject: Map + onNavigate?: () => void +}) { + const content = ( +
+ {projects.map((p) => ( + + ))} +
+ ) + + return ( +
+ + } + stopPropagation={false} + /> + +
+ ) +} + +/** Collapsed sidebar popover: single project group with expand/collapse */ +function CollapsedProjectGroup({ + project, + sessions, + onNavigate +}: { + project: Project + sessions: Session[] + onNavigate?: () => void +}) { + const location = useLocation() + const navigate = useNavigate() + const { running } = usePresence() + const isActiveProject = location.pathname.startsWith( + `/projects/${project.id}` + ) + const [open, setOpen] = useState(isActiveProject) + + const projectName = project.is_default ? project.name : project.name + + return ( +
+ {/* Project header */} +
setOpen(!open)} + > + + + + + {projectName} + + + {sessions.length} + +
+ {/* Sessions */} + {open && sessions.length > 0 && ( +
+ {sessions.map((s) => { + const isActive = location.pathname.includes(`/sessions/${s.id}`) + return ( +
{ + navigate(`/projects/${project.id}/sessions/${s.id}`) + onNavigate?.() + }} + > + {s.title} + {(running.has(s.id) || s.running) && ( + + )} + {isActive && ( + + + + )} +
+ ) + })} +
+ )} +
+ ) +} + +function RecentEmpty() { + const { t } = useT() + return ( +
+ +

{t.nav.recentEmpty}

+
+ ) +} + +function ProjectGroup({ + project, + sessions, + onNavigate, + onEditProject +}: { + project: Project + sessions: Session[] + onNavigate?: () => void + onEditProject?: (p: Project) => void +}) { + const { t } = useT() + const { modal } = App.useApp() + const location = useLocation() + const navigate = useNavigate() + const revalidator = useRevalidator() + + const isActiveProject = location.pathname.startsWith( + `/projects/${project.id}` + ) + const [open, setOpen] = useState(isActiveProject || project.is_default) + + useEffect(() => { + if (isActiveProject) setOpen(true) + }, [isActiveProject]) + + // All sessions are shown directly (no secondary fold / "show all" toggle). + const visibleSessions = sessions + + const handleDeleteProject = () => { + modal.confirm({ + title: t.sidebar.deleteProject, + content: t.sidebar.confirmDeleteProject, + okText: t.sidebar.confirmOk, + cancelText: t.sidebar.confirmCancel, + okButtonProps: { danger: true }, + onOk: async () => { + await api.deleteProject(project.id) + revalidator.revalidate() + if (isActiveProject) navigate('/') + } + }) + } + + const projectMenu: MenuProps = { + items: [ + { + key: 'edit', + label: t.sidebar.editProject, + onClick: () => { + onEditProject?.(project) + } + }, + ...(!project.is_default + ? [ + { + key: 'delete', + label: t.sidebar.deleteProject, + danger: true, + onClick: handleDeleteProject + } + ] + : []) + ] + } + + const projectName = project.name + + return ( +
+ {/* Project header row */} +
setOpen(!open)} + > + {/* Chevron */} + { + e.stopPropagation() + setOpen(!open) + }} + > + + + {/* Project name — click to enter project detail */} + { + e.stopPropagation() + navigate(`/projects/${project.id}`) + onNavigate?.() + }} + > + {projectName} + + {/* Count */} + + {sessions.length} + + {/* Add session */} + + } + variant="ghost" + size="xs" + className="!shrink-0 !opacity-0 !transition-opacity hover:!text-msa-purple-5 group-hover:!opacity-100" + onClick={() => { + navigate(`/projects/${project.id}/new`) + onNavigate?.() + }} + /> + + {/* More menu — extra wrapper needed because Dropdown close events bypass the trigger button */} + e.stopPropagation()}> + + + } + variant="ghost" + size="xs" + className="!shrink-0 !opacity-0 !transition-opacity hover:!text-msa-purple-5 group-hover:!opacity-100" + /> + + + +
+ + {/* Session list */} + {open && ( +
+ {sessions.length === 0 ? ( +

+ {t.widgets.empty} +

+ ) : ( + <> + {visibleSessions.map((s) => ( + + ))} + + )} +
+ )} +
+ ) +} + +function SessionItem({ + session, + projectId, + onNavigate +}: { + session: Session + projectId: string + onNavigate?: () => void +}) { + const { t } = useT() + const { modal } = App.useApp() + const navigate = useNavigate() + const location = useLocation() + const revalidator = useRevalidator() + const { running } = usePresence() + const isRunning = running.has(session.id) || !!session.running + const [renameOpen, setRenameOpen] = useState(false) + const [renameValue, setRenameValue] = useState('') + + const handleRename = async () => { + const title = renameValue.trim() + if (!title || title === session.title) { + setRenameOpen(false) + return + } + await api.updateSession(session.id, { title }) + revalidator.revalidate() + setRenameOpen(false) + } + + const handleDeleteSession = () => { + modal.confirm({ + title: t.sidebar.deleteSession, + content: t.sidebar.confirmDeleteSession, + okText: t.sidebar.confirmOk, + cancelText: t.sidebar.confirmCancel, + okButtonProps: { danger: true }, + onOk: async () => { + await api.deleteSession(session.id) + revalidator.revalidate() + const isActive = location.pathname.includes(`/sessions/${session.id}`) + if (isActive) navigate(`/projects/${projectId}`) + } + }) + } + + const sessionMenu: MenuProps = { + items: [ + { + key: 'rename', + label: t.sidebar.renameSession, + onClick: () => { + setRenameValue(session.title) + setRenameOpen(true) + } + }, + { + key: 'delete', + label: t.sidebar.deleteSession, + danger: true, + onClick: handleDeleteSession + } + ] + } + + // Bind the active highlight to the real browser URL (not NavLink's router + // `isActive`), so a session opened via the chat's mid-stream replaceState is + // highlighted immediately — the router location can lag the address bar. + const to = `/projects/${projectId}/sessions/${session.id}` + const active = useUrlPath() === to + return ( + <> + + {session.title} + {isRunning && ( + + )} + { + e.stopPropagation() + e.preventDefault() + }} + > + + } + variant="ghost" + size="xs" + className="!shrink-0 !opacity-0 !transition-opacity group-hover:!opacity-100" + /> + + + + setRenameOpen(false)} + destroyOnHidden + > + setRenameValue(e.target.value)} + onPressEnter={handleRename} + /> + + + ) +} diff --git a/webui/frontend/app/components/messages/ArtifactFiles.tsx b/webui/frontend/app/components/messages/ArtifactFiles.tsx new file mode 100644 index 000000000..b992028d4 --- /dev/null +++ b/webui/frontend/app/components/messages/ArtifactFiles.tsx @@ -0,0 +1,67 @@ +import { FileCard } from '~/components/common/FileCard' +import { useT } from '~/lib/i18n' +import { useWorkspaceFileSet } from '~/lib/workspaceFiles' +import type { OnOpenFile } from './types' + +/** + * The turn's deliverables: workspace files the agent wrote/edited during its + * tool-call loop (`changed_files` from the loop_end boundary), rendered as + * file cards after the summary. Reuses the attachment FileCard styling + * (UserBubble's counterpart, left-aligned for the assistant side); cards open + * the file in the workspace rail, and a file the user has since deleted + * degrades to the disabled "deleted" card via the live workspace path set. + */ +export function ArtifactFiles({ + paths, + onOpenFile +}: { + paths: string[] + onOpenFile?: OnOpenFile +}) { + const { t } = useT() + const fileSet = useWorkspaceFileSet() + if (paths.length === 0) return null + + return ( +
+ {paths.map((path) => { + const name = path.split('/').pop() || path + // Unknown set (provider not mounted yet) → assume it exists; the set + // refresh flips the state as soon as it lands. + const deleted = fileSet ? !fileSet.has(path) : false + const card = ( + + ) + if (deleted || !onOpenFile) { + return ( +
+ {card} +
+ ) + } + return ( +
onOpenFile(path)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onOpenFile(path) + } + }} + className="cursor-pointer rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-msa-line-2" + > + {card} +
+ ) + })} +
+ ) +} diff --git a/webui/frontend/app/components/messages/AssistantMessage.tsx b/webui/frontend/app/components/messages/AssistantMessage.tsx new file mode 100644 index 000000000..ae1f73338 --- /dev/null +++ b/webui/frontend/app/components/messages/AssistantMessage.tsx @@ -0,0 +1,244 @@ +import { Markdown } from '~/components/common/Markdown' +import type { AgentMessage, AgentPart } from '~/lib/agentProvider' +import type { OnOpenStep, OnOpenFile } from './types' +import { ThoughtsFlow } from './ThoughtsFlow' +import { TaskPlan } from './TaskPlan' +import { ToolBatch } from './ToolBatch' +import { TurnProcess } from './TurnProcess' +import { splitTurn } from './turnSplit' +import { ArtifactFiles } from './ArtifactFiles' +import { ErrorCard } from './ErrorCard' +import { TurnPlan } from './TurnPlan' + +type StepPart = Extract +type RenderGroup = + | { kind: 'steps'; parts: StepPart[]; endIdx: number; group: unknown } + | { kind: 'single'; part: Exclude; endIdx: number } + +/** History-replayed approved authorizations render nothing (the adjacent + * tool step shows the invocation) — exclude them from grouping so the + * "used N tools" count matches what's actually visible. */ +function isHiddenStep(p: StepPart): boolean { + return ( + p.step.kind === 'authorization' && + String(p.step.meta.state ?? '') === 'approved' && + !p.step.meta.request_id + ) +} + +/** Group step parts by the SERVER-ASSIGNED tool-round id (`meta.group`): + * one assistant reply's tool-call set shares one id (stamped by the live + * mapper and history reconstruction alike) — the frontend only mirrors that + * set under a nested accordion, it never invents its own grouping. Steps + * without a group id (defensive fallback) render standalone. */ +function groupParts(parts: AgentPart[]): RenderGroup[] { + const groups: RenderGroup[] = [] + parts.forEach((part, i) => { + if (part.kind === 'step') { + const prev = groups[groups.length - 1] + const group = part.step.meta.group + if (isHiddenStep(part)) { + // Invisible, but must not split the round it sits inside. + if (prev?.kind === 'steps' && prev.endIdx === i - 1) prev.endIdx = i + return + } + if ( + group != null && + prev?.kind === 'steps' && + prev.endIdx === i - 1 && + prev.group === group + ) { + prev.parts.push(part) + prev.endIdx = i + } else { + groups.push({ kind: 'steps', parts: [part], endIdx: i, group }) + } + return + } + groups.push({ kind: 'single', part, endIdx: i }) + }) + return groups +} + +/** + * Rich assistant message. A turn is presented in two stages (design spec): + * + * - while it runs, every block sits FLAT under a live "processing Ns ..." + * header (no accordion — the user watches the work); + * - once the SDK closes the tool-call loop, the turn's trailing text block IS + * the final summary: it renders on its own, and everything before it folds + * into the collapsible "processed Ns" card above it. + * + * Falls back to plain markdown of `content` when a message has no parts (e.g. + * an error/fallback message). + */ +export function AssistantMessage({ + message, + streaming, + sessionId, + onOpenStep, + onOpenFile +}: { + message: AgentMessage + streaming: boolean + /** Backend session id — the plan chip fetches the session plan with it. */ + sessionId?: string | null + onOpenStep?: OnOpenStep + onOpenFile?: OnOpenFile +}) { + const parts = message.parts + + if (!parts || parts.length === 0) { + return ( +
+ {/* A turn that has started but produced nothing yet still shows the + "processing Ns" header, so the counter is visible from 0 and grows + naturally. Without this the header first appeared with the first + part — after a slow model's think delay it popped in already + reading "8s". */} + {streaming && message.turnStartedAt != null && ( + + {null} + + )} + {message.content && ( + + )} +
+ ) + } + + // An interrupted turn produced no summary — it stays in the "processing" + // presentation forever (per spec), so the partial work isn't hidden behind a + // "processed" header that never really happened. + const { loopDone, processParts, summary } = splitTurn(parts, streaming) + + const renderParts = (source: AgentPart[], frozen: boolean) => + groupParts(source).map((g, gi) => { + // Expanded if it's the last meaningful block; auto-collapses when new + // parts arrive (checked against the ORIGINAL parts order). Blocks folded + // into the finished "processed" card are all history — keep them closed. + const isLast = + !frozen && + !source.slice(g.endIdx + 1).some((p) => p.kind !== 'interrupted') + if (g.kind === 'steps') { + // One server tool round (1..N calls) → one nested accordion. + return ( + + ) + } + const part = g.part + if (part.kind === 'thought') { + return ( + + ) + } + if (part.kind === 'tasks') { + // A conversation plan block is a frozen SNAPSHOT of the plan at that + // point in the stream (each update appends a new one) — never animated, + // even mid-turn. The composer's pinned panel is the live view. + return ( + + ) + } + if (part.kind === 'interrupted') { + return null + } + // Text: only the last text block gets the streaming cursor. + return ( + part.text && ( + + ) + ) + }) + + // changed_files carries the reserved "plan.md" marker when the loop rewrote + // the todo list (pairs with `plan_file`). The plan lives in the SESSION dir, + // not the workspace, so it is NOT a file card: it renders once more at the + // end as the flat TaskPlan list (TurnPlan). Out-of-workspace writes and + // custom-named plan files are already filtered server-side + // (sessions.changed_files_in_rows), so only the literal "plan.md" marker + // reaches here. + const deliverables = (message.changedFiles ?? []).filter( + (p) => p !== 'plan.md' + ) + const planTouched = + !!message.planFile || (message.changedFiles ?? []).includes('plan.md') + // This turn's FINAL plan state = the LAST plan snapshot the turn produced + // (each todo_write appends one). TurnPlan renders THIS per-turn state, not + // the session's current plan — an old turn's plan must not reflect a later + // turn's edits. Undefined for a render-only turn (no snapshot); TurnPlan then + // falls back to GET /plan. + const turnPlanTasks = [...parts] + .reverse() + .find((p): p is Extract => p.kind === 'tasks') + ?.tasks + + // Errors never fold into the "processed" accordion: a turn/API failure must + // be visible without expanding anything (a message that is ONLY an error + // would otherwise be an empty-looking collapsed card). + const errorParts = processParts.filter( + (p): p is Extract => p.kind === 'error' + ) + const foldableParts = processParts.filter((p) => p.kind !== 'error') + + return ( +
+ {/* Header shows while the turn RUNS (even before its first block) or once + it has folded content — never for a bare finished reply. */} + {(foldableParts.length > 0 || + (streaming && message.turnStartedAt != null)) && ( + + {renderParts(foldableParts, loopDone)} + + )} + {/* Alert cards sit outside the fold, before the summary. */} + {errorParts.map((p, i) => ( + + ))} + {summary && } + {/* The turn's deliverables (files written/edited this loop) close the + message, after the summary. */} + {loopDone && deliverables.length > 0 && ( + + )} + {/* The final todo plan is replayed once more as a flat task list (not a + file card), per the design — showing THIS turn's final state (the + per-turn snapshot), falling back to GET /plan only for a render-only + turn that produced no snapshot. */} + {loopDone && planTouched && ( + + )} +
+ ) +} diff --git a/webui/frontend/app/components/messages/ErrorCard.tsx b/webui/frontend/app/components/messages/ErrorCard.tsx new file mode 100644 index 000000000..4ad6b4162 --- /dev/null +++ b/webui/frontend/app/components/messages/ErrorCard.tsx @@ -0,0 +1,45 @@ +import { ExclamationCircleFilled } from '@ant-design/icons' +import { useT } from '~/lib/i18n' + +/** + * ErrorCard — a turn/API failure as its own alert block. + * + * Errors are NOT part of the reply: they get an alert-framed card (danger + * tokens, own icon + title) instead of being appended to the body text, so a + * failure never reads as something the agent said. Used by both the live + * stream and history replay (identical shape, no drift). + * + * `recoverable` distinguishes a failure the loop absorbed and kept going from + * one that ended the turn — the hint line makes that explicit instead of + * leaving the user guessing. + */ +export function ErrorCard({ + text, + recoverable = false +}: { + text: string + /** Whether the error re-entered the model context (the turn continued). */ + recoverable?: boolean +}) { + const { t } = useT() + if (!text) return null + return ( +
+
+ {/* No in-house alert glyph yet — antd fallback per the icon policy. */} + +
+
+ {recoverable ? t.chat.errorRecoverable : t.chat.errorTitle} +
+
+ {text} +
+
+
+
+ ) +} diff --git a/webui/frontend/app/components/messages/InlineCode.tsx b/webui/frontend/app/components/messages/InlineCode.tsx new file mode 100644 index 000000000..993006312 --- /dev/null +++ b/webui/frontend/app/components/messages/InlineCode.tsx @@ -0,0 +1,11 @@ +/** Inline-code style wrapper for dynamic entities inside step card headers + * (file paths, tool/MCP names, skill names, search queries…) — the visual + * analog of markdown backticks, on the msa fill token so it reads on both the + * fill-1 shell cards and the fill-2 accordion headers. */ +export function InlineCode({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/webui/frontend/app/components/messages/MessageList.css b/webui/frontend/app/components/messages/MessageList.css new file mode 100644 index 000000000..1ed12d101 --- /dev/null +++ b/webui/frontend/app/components/messages/MessageList.css @@ -0,0 +1,12 @@ +/* MessageList.css */ +/* Copy button on HISTORY replies: hidden until the bubble is hovered (the + latest reply keeps it always visible — no msgl-copy-hover class there). + Targets antd's .ant-bubble wrapper, unreachable via classNames prop. */ +.msgl-copy-hover { + opacity: 0; + transition: opacity 0.15s ease; +} +.ant-bubble:hover .msgl-copy-hover, +.msgl-copy-hover:focus-within { + opacity: 1; +} diff --git a/webui/frontend/app/components/messages/MessageList.tsx b/webui/frontend/app/components/messages/MessageList.tsx new file mode 100644 index 000000000..1593cc276 --- /dev/null +++ b/webui/frontend/app/components/messages/MessageList.tsx @@ -0,0 +1,236 @@ +import { Bubble } from '@ant-design/x' +import { Tooltip } from 'antd' +import { CheckOutlined } from '@ant-design/icons' +import { + type ComponentRef, + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState +} from 'react' +import { useT } from '~/lib/i18n' +import type { AgentMessage } from '~/lib/agentProvider' +import { AssistantMessage } from './AssistantMessage' +import { splitTurn } from './turnSplit' +import { UserBubble } from './UserBubble' +import type { OnOpenStep, OnOpenFile } from './types' +import { IconButton } from '../common/IconButton' +import DownloadIcon from '~/assets/icons/download.svg?react' +import CopyIcon from '~/assets/icons/copy.svg?react' +import './MessageList.css' + +export interface ChatMessageItem { + id: string + message: AgentMessage + status: string +} + +/** Copy-reply action: on success the icon flips to a check for a moment + * instead of raising a toast — feedback stays inside the bubble footer. + * (CheckOutlined: no in-house check glyph asset yet, the established + * fallback.) */ +function CopyReplyButton({ text }: { text: string }) { + const { t } = useT() + const [copied, setCopied] = useState(false) + const timerRef = useRef | null>(null) + useEffect( + () => () => { + if (timerRef.current) clearTimeout(timerRef.current) + }, + [] + ) + const copy = async () => { + try { + await navigator.clipboard.writeText(text) + setCopied(true) + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => setCopied(false), 2000) + } catch { + /* clipboard blocked (insecure context) — ignore */ + } + } + return ( + + + ) : ( + + ) + } + className="text-msa-text-3 hover:!text-msa-text-1" + onClick={() => void copy()} + /> + + ) +} + +/** Imperative handle so the host (ChatPanel) can jump the list to the latest + * message — e.g. the moment the user starts typing in the composer. */ +export interface MessageListHandle { + scrollToBottom: () => void +} + +interface BubbleContent { + message: AgentMessage + streaming: boolean +} + +/** + * Chat bubble list. Scrolling and auto-follow on new messages are delegated to + * Bubble.List's built-in scroll container (`autoScroll`) — no external overflow + * wrapper. The back-to-bottom button also rides on Bubble.List's own API: it + * toggles from the built-in scroll-box position and scrolls via the list ref's + * `scrollTo({ top: 'bottom' })`, so no hand-rolled scroll container is added. + */ +export const MessageList = forwardRef< + MessageListHandle, + { + items: ChatMessageItem[] + /** Backend session id, threaded to the plan chip (null until the first + * turn of a brand-new chat has created the session). */ + sessionId?: string | null + onOpenStep?: OnOpenStep + onOpenFile?: OnOpenFile + } +>(function MessageList({ items, sessionId, onOpenStep, onOpenFile }, ref) { + const { t } = useT() + const listRef = useRef>(null) + const [showScrollDown, setShowScrollDown] = useState(false) + + useImperativeHandle( + ref, + () => ({ + scrollToBottom: () => { + // Guard: before the list renders its scroll box (empty conversation), + // Bubble.List's scrollTo destructures an undefined scrollBoxDom and + // throws ("Cannot destructure property 'scrollHeight'…"). + if (!listRef.current?.scrollBoxNativeElement) return + listRef.current.scrollTo({ top: 'bottom' }) + } + }), + [] + ) + + // Watch Bubble.List's built-in scroll-box. It uses a column-reverse viewport, + // so scrollTop is 0 at the visual bottom and grows negative when scrolling up + // to read history; show the button once we move away from the bottom. + useEffect(() => { + const box = listRef.current?.scrollBoxNativeElement + if (!box) return + const onScroll = () => setShowScrollDown(Math.abs(box.scrollTop) > 120) + onScroll() + box.addEventListener('scroll', onScroll, { passive: true }) + return () => box.removeEventListener('scroll', onScroll) + }, [items]) + + // The newest assistant bubble in the list — its copy button stays visible; + // all earlier replies only reveal theirs on hover. + const latestAssistantId = [...items] + .reverse() + .find(({ message }) => message.role === 'assistant')?.id + + const bubbleItems = items.map(({ id, message, status }) => { + const hasBody = !!message.content || (message.parts?.length ?? 0) > 0 + const inFlight = status === 'loading' || status === 'updating' + const interrupted = + message.role === 'assistant' && + message.parts?.some((p) => p.kind === 'interrupted') + // Copy target: the reply's FINAL summary only (the text after the + // "processed" fold) — mid-turn narration folded into the accordion is + // process detail, not the answer. Parts-less messages (error/fallback) + // copy their plain content; an interrupted turn has no summary → no button. + const copyText = + message.role === 'assistant' && !inFlight + ? message.parts?.length + ? (splitTurn(message.parts, false).summary?.text ?? '').trim() + : (message.content || '').trim() + : '' + // Only the LATEST reply keeps its copy button always visible; history + // replies reveal it on bubble hover (CSS: .msgl-copy-hover). + const isLatestReply = id === latestAssistantId + return { + key: id, + role: message.role, + content: { + message, + streaming: inFlight + } satisfies BubbleContent, + // Show the built-in loading indicator for the in-flight assistant bubble + // until it has actual body. `updating` (not just `loading`) is included + // because the turn's first frame is a metadata `session` frame that flips + // the status to `updating` while the body is still empty (backend still + // "thinking" before the first content token). + // Once the turn frame lands (`turnStartedAt`), AssistantMessage renders + // its own "processing Ns" header — the dots would then be a second, + // redundant progress hint stacked above it. + loading: inFlight && !hasBody && message.turnStartedAt == null, + footer: + copyText || interrupted ? ( +
+ {/* Leftmost action: copy the reply text. */} + {copyText ? ( + + + + ) : ( + + )} + {interrupted && ( + + {t.chat.interrupted} + + )} +
+ ) : undefined + } + }) + + return ( +
+ ( + + ) + }, + assistant: { + placement: 'start', + variant: 'borderless', + contentRender: (content: BubbleContent) => ( + + ) + } + }} + items={bubbleItems} + /> + {showScrollDown && ( + + { + if (!listRef.current?.scrollBoxNativeElement) return + listRef.current.scrollTo({ top: 'bottom', behavior: 'smooth' }) + }} + className="absolute bottom-5 right-5 z-10 !rounded-full" + variant="tonal" + icon={} + > + + )} +
+ ) +}) diff --git a/webui/frontend/app/components/messages/MessageListSkeleton.tsx b/webui/frontend/app/components/messages/MessageListSkeleton.tsx new file mode 100644 index 000000000..5b1e255ca --- /dev/null +++ b/webui/frontend/app/components/messages/MessageListSkeleton.tsx @@ -0,0 +1,63 @@ +import { Skeleton } from 'antd' +import { DeferredSkeleton } from '~/components/common/DeferredSkeleton' +import type { ChatMessageItem } from './MessageList' + +/** + * One skeleton row mimicking a chat bubble: a rounded content block, no avatar + * (the real Bubble.List renders none). `mine` flips it to the right (user) + * side, matching the real placement (assistant start / user end). + */ +function SkeletonRow({ + mine, + rows, + width +}: { + mine?: boolean + rows: number + width: number +}) { + return ( +
+
+ +
+
+ ) +} + +/** Approximate the bubble's line count from its content length. */ +function estimateRows(len: number): number { + return Math.max(1, Math.min(4, Math.ceil(len / 80))) +} + +/** Approximate the bubble's width from its content length (side-capped). */ +function estimateWidth(len: number, mine: boolean): number { + const max = mine ? 280 : 440 + return Math.min(max, Math.max(120, len * 7)) +} + +/** + * Loading placeholder for the chat message list, shown while history hydrates + * (see ChatPanel). Renders one skeleton bubble per real message so the count + * and left/right placement (by `role`) match the conversation about to appear. + * Occupies the same flex-1 slot as MessageList (the surrounding chat layout + * provides the centered column and the SSR-safe composer stays mounted below). + */ +export function MessageListSkeleton({ items }: { items: ChatMessageItem[] }) { + return ( + + {items.map(({ id, message }) => { + const mine = message.role === 'user' + const len = message.content?.length ?? 0 + return ( + + ) + })} + + ) +} diff --git a/webui/frontend/app/components/messages/StepCard.tsx b/webui/frontend/app/components/messages/StepCard.tsx new file mode 100644 index 000000000..a4d1b08df --- /dev/null +++ b/webui/frontend/app/components/messages/StepCard.tsx @@ -0,0 +1,367 @@ +import { Typography } from 'antd' +import type { ReactNode } from 'react' +import { FileTypeIcon } from '~/components/common/FileCard' +import { useT } from '~/lib/i18n' +import { useFileExists } from '~/lib/workspaceFiles' +import { InlineCode } from './InlineCode' +import { faviconOf, parseWebSearchResults } from './searchResults' +import type { AgentStep } from '~/lib/agentProvider' +import type { OnOpenStep, OnOpenFile } from './types' +import { TerminalStepCard } from './steps/TerminalStepCard' +import { ToolCallStepCard } from './steps/ToolCallStepCard' +import { ArtifactStepCard } from './steps/ArtifactStepCard' +import { AuthConfirmStepCard } from './steps/AuthConfirmStepCard' +import LoadSkillIcon from '~/assets/icons/load-skill.svg?react' +import SearchIcon from '~/assets/icons/search.svg?react' +import MemoryIcon from '~/assets/icons/memory.svg?react' +import JumpIcon from '~/assets/icons/jump.svg?react' +import GlobeIcon from '~/assets/files/web.svg?react' + +/** + * Shared shell for single-line step cards: leading icon + title + trailing + * jump chevron. Clicking opens the workspace rail via `onClick`. When + * `disabled` (e.g. a file whose workspace entry was deleted) it renders as a + * non-interactive card — no chevron, `cursor-not-allowed`, muted title — with + * an optional trailing `note` (e.g. "this file was deleted"). + */ +function StepCardShell({ + icon, + children, + onClick, + disabled = false, + note, + maxWidthClass = 'max-w-full' +}: { + icon: ReactNode + children: ReactNode + onClick?: () => void + disabled?: boolean + note?: ReactNode + maxWidthClass?: string +}) { + if (disabled) { + return ( +
+ + {icon} + + + {children} + + {note && ( + {note} + )} +
+ ) + } + return ( + + ) +} + +/** File step card ("modified: x" / "read: x") with LIVE existence: a webui + * rename/delete flips it to the disabled "deleted" card immediately (via the + * workspace file-set context), no reload needed. */ +function FileStepCard({ + path, + label, + serverExists, + onOpen +}: { + path: string + label: string + serverExists: boolean + onOpen: () => void +}) { + const { t } = useT() + const exists = useFileExists(path, serverExists) + const icon = + if (!exists) { + return ( + + {label}: + {path} + + ) + } + return ( + + {label}: + {path} + + ) +} + +/** Human label + detail for a tool that is CURRENTLY executing (status + * "running"). Reuses the finished-card i18n strings; the spinner conveys the + * in-progress state, so no separate "…ing" wording is needed. */ +function runningDescriptor( + kind: string, + meta: Record, + s: ReturnType['t']['chat'] +): { label: string; detail: string } { + switch (kind) { + case 'search': + return String(meta.scope ?? '') === 'files' + ? { label: s.stepSearchFiles, detail: String(meta.query ?? '') } + : { label: s.stepSearch, detail: String(meta.query ?? '') } + case 'browser': + return { label: s.stepBrowser, detail: String(meta.title ?? meta.url ?? '') } + case 'terminal': + return { label: s.stepTerminal, detail: '' } + case 'file_read': + return { label: s.stepFileRead, detail: String(meta.path ?? '') } + case 'file_write': + return { label: s.stepFileWrite, detail: String(meta.path ?? '') } + case 'file_edit': + return { label: s.stepFileEdit, detail: String(meta.path ?? '') } + case 'skill_load': + return { label: s.stepLoadSkill, detail: String(meta.name ?? '') } + case 'memory': + return { + label: String(meta.action ?? '') === 'read' ? s.stepMemoryRead : s.stepMemory, + detail: '' + } + default: + return { label: s.stepInvoke, detail: String(meta.name ?? '') } + } +} + +/** Live "executing" card shown from tool_call_started until the result frame + * (same call_id) replaces it — so a slow tool (e.g. web_search) gives immediate + * feedback instead of a blank gap. A leading spinner marks the in-progress + * state; the label says which tool is running. */ +function RunningStepCard({ step }: { step: AgentStep }) { + const { t } = useT() + const { label, detail } = runningDescriptor(step.kind, step.meta, t.chat) + const tip = detail ? `${label} ${detail}` : label + return ( +
+ + + {label} + {detail ? ( + <> + {' '} + {detail} + + ) : null} + +
+ ) +} + +/** Dispatches a step to the correct card by its kind. */ +export function StepCard({ + step, + onOpenStep, + onOpenFile, + isLast +}: { + step: AgentStep + onOpenStep?: OnOpenStep + onOpenFile?: OnOpenFile + /** Whether this step is the last meaningful part of its message — accordion + * cards default expanded while last, auto-collapse once newer parts arrive. */ + isLast?: boolean +}) { + const { t } = useT() + const meta = step.meta + const open = () => onOpenStep?.(step) + + // A tool still executing (emitted on tool_call_started): show a live + // "running" card until its result frame (same call_id) replaces it in place. + if (meta.status === 'running') return + + switch (step.kind) { + case 'terminal': + return ( + + ) + case 'artifact': + return + case 'authorization': { + // History-replayed approved authorizations (no request_id) don't render — + // the adjacent tool_call step already shows the full invocation. LIVE + // approved cards (request_id present) stay visible while the tool runs; + // agentProvider replaces them in place when the result step arrives. + const authState = (meta.state as string) ?? 'pending' + if (authState === 'approved' && !meta.request_id) return null + return + } + case 'tool_call': + return ( + + ) + case 'skill_load': + return ( + + ) : ( + + ) + } + title={ + <> + {t.chat.stepLoadSkill}{' '} + {String(meta.name ?? '')} + + } + titleText={`${t.chat.stepLoadSkill} ${String(meta.name ?? '')}`} + /> + ) + case 'file_read': + case 'file_write': + case 'file_edit': { + const path = String(meta.path ?? '') + // Distinct label per file operation: read / full-content write / + // in-place edit — the three render as distinguishable cards. + const label = + step.kind === 'file_read' + ? t.chat.stepFileRead + : step.kind === 'file_edit' + ? t.chat.stepFileEdit + : t.chat.stepFileWrite + // Errored file steps (permission denied, interrupted, etc.) render as a + // ToolCallStepCard accordion showing what was attempted + the error. + if (meta.status === 'error') { + return ( + + ) + } + return ( + (onOpenFile ? onOpenFile(path) : open())} + /> + ) + } + case 'browser': { + const title = String(meta.title ?? meta.url ?? '') + return ( + } + title={ + <> + {t.chat.stepBrowser}{' '} + {title} + + } + titleText={`${t.chat.stepBrowser} ${title}`} + /> + ) + } + case 'search': { + const query = String(meta.query ?? '') + // File-scoped searches (grep/glob) keep the inline accordion; WEB + // searches are a one-line card that opens the result list in the right + // rail (globe icon, result count + stacked favicons once finished). + if (String(meta.scope ?? '') === 'files') { + return ( + } + title={ + <> + {t.chat.stepSearchFiles}{' '} + {query} + + } + titleText={`${t.chat.stepSearchFiles} ${query}`} + /> + ) + } + const results = parseWebSearchResults(meta.result) + const favicons = results + .map((r) => faviconOf(r.url)) + .filter(Boolean) + .slice(0, 3) + return ( + } onClick={open}> + {t.chat.stepSearch}{' '} + {query} + {results.length > 0 && ( + <> + {' '} + + {t.chat.searchedPages.replace('{n}', String(results.length))} + + + {favicons.map((src, i) => ( + 0 ? '-ml-1.5' : '' + }`} + onError={(e) => { + ;(e.target as HTMLImageElement).style.display = 'none' + }} + /> + ))} + + + )} + + ) + } + case 'memory': { + const label = + String(meta.action ?? '') === 'read' + ? t.chat.stepMemoryRead + : t.chat.stepMemory + return ( + } + title={label} + /> + ) + } + default: + return null + } +} diff --git a/webui/frontend/app/components/messages/StepDetailRail.tsx b/webui/frontend/app/components/messages/StepDetailRail.tsx new file mode 100644 index 000000000..c2d8f9201 --- /dev/null +++ b/webui/frontend/app/components/messages/StepDetailRail.tsx @@ -0,0 +1,408 @@ +import type { ReactNode } from 'react' +import { IconButton } from '~/components/common/IconButton' +import { useT } from '~/lib/i18n' +import type { Dict } from '~/lib/i18n' +import type { AgentStep } from '~/lib/agentProvider' +import { faviconOf, hostOf, parseWebSearchResults } from './searchResults' +import JumpIcon from '~/assets/icons/jump.svg?react' +import GlobeIcon from '~/assets/files/web.svg?react' +import CloseIcon from '~/assets/icons/close.svg?react' + +/** Human title for the rail header, per step kind. */ +function titleFor(step: AgentStep, t: Dict): string { + const meta = step.meta + const s = (v: unknown) => String(v ?? '') + switch (step.kind) { + case 'file_read': + return `${t.chat.stepFileRead}:${s(meta.path)}` + case 'file_write': + return `${t.chat.stepFileWrite}:${s(meta.path)}` + case 'file_edit': + return `${t.chat.stepFileEdit}:${s(meta.path)}` + case 'skill_load': + return `${t.chat.stepLoadSkill} ${s(meta.name)}` + case 'browser': + return `${t.chat.stepBrowser} ${s(meta.title ?? meta.url)}` + case 'terminal': + return t.chat.stepTerminal + case 'search': { + // Web searches title with the result count once finished (design spec: + // "found N pages"); otherwise fall back to the query. + const n = parseWebSearchResults(meta.result).length + if (n > 0) return t.chat.searchedPages.replace('{n}', String(n)) + return `${t.chat.stepSearch} ${s(meta.query)}` + } + case 'memory': + return s(meta.action) === 'read' + ? t.chat.stepMemoryRead + : t.chat.stepMemory + case 'authorization': + return t.chat.stepAuthTitle + case 'artifact': + return s(meta.name) + default: + return `${t.chat.stepInvoke} ${s(meta.tool ?? meta.name)}` + } +} + +function Section({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
+ {label} +
+ {children} +
+ ) +} + +function CodeBlock({ + children, + tone = 'default' +}: { + children: ReactNode + tone?: 'default' | 'error' +}) { + return ( +
+      {children}
+    
+ ) +} + +function InlineCode({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +function EmptyHint({ label }: { label: string }) { + return
{label}
+} + +const asStr = (v: unknown): string => (typeof v === 'string' ? v : '') + +const asRecord = (v: unknown): Record => + v && typeof v === 'object' && !Array.isArray(v) + ? (v as Record) + : {} + +/** + * Search result cards (web_search unified shape), per the design spec: each + * card shows a site header (favicon + hostname + jump chevron), the page + * title, and — when present — a clamped summary. The whole card opens the + * url. Falls back to the raw payload (e.g. file_system grep/glob text) when + * it isn't a `{results:[...]}` object. + */ +function SearchResults({ raw }: { raw: string }) { + const results = parseWebSearchResults(raw) + if (results.length === 0) { + return {raw} + } + return ( +
+ ) +} + +/** Extract readable file content from a read_file result (raw text, a + * `{path: content}` map, or a `{type:"file_unchanged", message}` note). */ +function fileReadContent(result: string): string { + try { + const obj = JSON.parse(result) + if (obj && typeof obj === 'object' && !Array.isArray(obj)) { + const rec = obj as Record + if (typeof rec.message === 'string' && rec.type) return rec.message + const strings = Object.values(rec).filter( + (v): v is string => typeof v === 'string' + ) + if (strings.length) return strings.join('\n\n') + } + } catch { + /* not JSON — show as-is */ + } + return result +} + +/** Per-kind body: the same clicked-step data rendered differently by type. */ +function StepDetailBody({ step, t }: { step: AgentStep; t: Dict }) { + const meta = step.meta ?? {} + const argRec = asRecord(meta.arguments) + const hasArgs = Object.keys(argRec).length > 0 + const result = asStr(meta.result) + const error = asStr(meta.error) + const errorSection = error ? ( +
+ {error} +
+ ) : null + + switch (step.kind) { + case 'search': { + const query = asStr(meta.query) + return ( + <> + {query && ( +
+ {query} +
+ )} + {result && ( +
+ +
+ )} + {errorSection} + {!query && !result && !error && ( + + )} + + ) + } + case 'memory': { + const content = asStr(argRec.content) || asStr(argRec.new_content) + return ( + <> + {content && ( +
+ {content} +
+ )} + {result && ( +
+ {result} +
+ )} + {errorSection} + {!content && !result && !error && ( + + )} + + ) + } + case 'terminal': { + const code = + asStr(meta.code) || asStr(argRec.command) || asStr(argRec.code) + return ( + <> + {code && ( +
+ {code} +
+ )} + {result && ( +
+ {result} +
+ )} + {errorSection} + {!code && !result && !error && ( + + )} + + ) + } + case 'file_read': + case 'file_write': + case 'file_edit': { + // The path is already in the header title; show the body content. + if (step.kind === 'file_read') { + const content = fileReadContent(result) + return ( + <> + {content && ( +
+ {content} +
+ )} + {errorSection} + {!content && !error && } + + ) + } + // file_write carries a full-content write (`content`); file_edit a + // diff-style old->new replacement. Argument names vary across tool + // dialects, so alias them all; otherwise an edit shows nothing (it + // carries no `content`). + const content = asStr(argRec.content) || asStr(argRec.file_text) + const oldText = + asStr(argRec.old) || asStr(argRec.old_string) || asStr(argRec.old_str) + const newText = + asStr(argRec.new) || asStr(argRec.new_string) || asStr(argRec.new_str) + const hasBody = !!(content || oldText || newText) + return ( + <> + {content && ( +
+ {content} +
+ )} + {!content && oldText && ( +
+ {oldText} +
+ )} + {!content && newText && ( +
+ {newText} +
+ )} + {result && ( +
+ {result} +
+ )} + {errorSection} + {!hasBody && !result && !error && ( + + )} + + ) + } + default: { + // Generic tool_call / browser / skill_load / artifact / authorization: + // the full invocation as tool + arguments + result. + const tool = asStr(meta.tool) || asStr(meta.name) + const empty = !tool && !hasArgs && !result && !error + return ( + <> + {tool && ( +
+ {tool} +
+ )} + {hasArgs && ( +
+ {JSON.stringify(argRec, null, 2)} +
+ )} + {result && ( +
+ {result} +
+ )} + {errorSection} + {empty && } + + ) + } + } +} + +/** + * Right-side squeezable rail showing the full detail of a clicked step card. + * Mirrors the workspace rail (SessionRightRail) layout — a filling column with a + * header (title + close) over a scrollable body — but is a dedicated component + * that renders each tool kind (search / memory / terminal / file / generic) + * differently, not the file workspace. + */ +export function StepDetailRail({ + step, + onClose, + clip = false +}: { + step: AgentStep + onClose: () => void + /** Clip the scrollable body (overflow hidden) while the rail is animating, so + * its content doesn't reflow or flash a scrollbar mid-transition. */ + clip?: boolean +}) { + const { t } = useT() + const title = titleFor(step, t) + + return ( +
+ {/* Header */} +
+

+ {title} +

+ } + variant="tonal" + size="sm" + onClick={onClose} + /> +
+ + {/* Body */} +
+
+ +
+
+
+ ) +} diff --git a/webui/frontend/app/components/messages/TaskPlan.tsx b/webui/frontend/app/components/messages/TaskPlan.tsx new file mode 100644 index 000000000..d77667405 --- /dev/null +++ b/webui/frontend/app/components/messages/TaskPlan.tsx @@ -0,0 +1,125 @@ +import { useEffect, useState } from 'react' +import { useT } from '~/lib/i18n' +import type { AgentTask } from '~/lib/agentProvider' +import TodoIcon from '~/assets/icons/todo.svg?react' +import TaskDoneIcon from '~/assets/icons/task-done.svg?react' +import TaskRunningIcon from '~/assets/icons/task-running.svg?react' +import TaskPausedIcon from '~/assets/icons/task-paused.svg?react' +import TaskWaitingIcon from '~/assets/icons/task-waiting.svg?react' +import ArrowDownIcon from '~/assets/icons/arrow-down.svg?react' + +/** Same status glyph set as the composer's thinking plan list (mirrored so + * both task lists read identically) — the design-spec circled icons, colored + * via currentColor so one asset covers light & dark. A "running" item without + * a live turn is stale plan-file state (e.g. an interrupted turn) — degrade + * to the paused glyph. */ +export function taskStatusIcon( + status: AgentTask['status'], + streaming: boolean +) { + switch (status) { + case 'done': + return + case 'running': + return streaming ? ( + + ) : ( + + ) + case 'pending': + return + default: + return + } +} + +/** + * Todo-plan card as an inline accordion (per the design spec): the header + * ("todo tasks" + done/total) toggles a timeline-style task list — status + * circles joined by a dashed spine. No right-rail detail anymore. + * + * Expansion follows the shared accordion convention: open while it's the + * message's LAST meaningful block, auto-collapses once newer parts arrive, + * manual toggling always available. + */ +export function TaskPlan({ + tasks, + isLast, + streaming = false +}: { + tasks: AgentTask[] + isLast?: boolean + /** Whether a turn is live — gates the animated "running" spinner. */ + streaming?: boolean +}) { + const { t } = useT() + const [expanded, setExpanded] = useState(isLast ?? false) + + useEffect(() => { + if (!isLast) setExpanded(false) + }, [isLast]) + + if (tasks.length === 0) return null + + const done = tasks.filter((task) => task.status === 'done').length + const total = tasks.length + + return ( +
+ {/* Header */} + + + {/* Body: timeline list with a dashed spine between status circles */} +
+
+
+ {tasks.map((task, i) => ( +
+ {/* Status circle + dashed connector down to the next item */} +
+ + {taskStatusIcon(task.status, streaming)} + + {i < tasks.length - 1 && ( + + )} +
+
+ {task.label} +
+
+ ))} +
+
+
+
+ ) +} diff --git a/webui/frontend/app/components/messages/ThoughtsFlow.tsx b/webui/frontend/app/components/messages/ThoughtsFlow.tsx new file mode 100644 index 000000000..6b6faf9ff --- /dev/null +++ b/webui/frontend/app/components/messages/ThoughtsFlow.tsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from 'react' +import { useT } from '~/lib/i18n' +import ThinkingIcon from '~/assets/icons/thinking.svg?react' +import ArrowDownIcon from '~/assets/icons/arrow-down.svg?react' + +/** Format elapsed seconds as "Ns" (< 60s) or "Nm Ns" (per the design). */ +function formatDuration(seconds: number): string { + const s = Math.max(0, Math.floor(seconds)) + if (s < 60) return `${s}s` + return `${Math.floor(s / 60)}m ${s % 60}s` +} + +/** + * A single reasoning block: a "thinking Ns..." header (a live counter that ticks + * up while streaming, then freezes at the reported duration) followed by the + * gray reasoning text. One is rendered per `thought` part, in stream order. + * + * While the model is still thinking (`!done`) the reasoning stays visible and + * cannot be collapsed, and the header counts up from `startedAt`. Once thinking + * finishes (`done`) it defaults to collapsed and shows the frozen elapsed time. + */ +export function ThoughtsFlow({ + text, + startedAt, + duration, + done, + isLast +}: { + text: string + startedAt?: number + duration?: number + done?: boolean + /** Whether this thought is currently the last meaningful part in the message. + * When true → expanded; when it becomes false (new parts arrived) → auto-collapse. */ + isLast?: boolean +}) { + const { t } = useT() + // done=false → live streaming; done=undefined (server history) or true → complete. + const isDone = done !== false + + const [expanded, setExpanded] = useState(isLast ?? false) + + // Auto-collapse when this thought is no longer the last part (streaming + // pushed new content after it). + useEffect(() => { + if (isDone && !isLast) setExpanded(false) + }, [isDone, isLast]) + + // Live elapsed seconds, ticked once a second while thinking is in flight. + const [elapsed, setElapsed] = useState(() => + startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0 + ) + useEffect(() => { + if (done || !startedAt) return + const tick = () => + setElapsed(Math.max(0, Math.floor((Date.now() - startedAt) / 1000))) + tick() + const id = setInterval(tick, 1000) + return () => clearInterval(id) + }, [done, startedAt]) + + if (!text) return null + + // Finished → the reported duration; live → the ticking counter. + // + // A duration of 0 is treated as "none": the SDK rounds the thinking time to + // whole seconds, so a sub-second block reports 0 — and a block cut short by + // Stop reports nothing at all (its end callback never runs, and history + // carries no start time). Rendering "0s" in the first case but nothing in the + // second made the label flicker from "0s" to blank when the canonical history + // replaced the live turn. "0s" carries no information anyway, so both cases + // now show the bare label. + const reported = duration != null && duration > 0 ? duration : undefined + const shown = isDone ? reported : elapsed + const timing = shown != null ? ` ${formatDuration(shown)}` : '' + const header = isDone + ? `${t.chat.thoughts}${timing}` + : `${t.chat.thoughts}${timing} ...` + + // Live thinking → always shown; finished → collapsed unless the user expands. + // `done` is explicitly `false` only during live streaming; `undefined` or `true` + // (from server history) means the thought is complete → default collapsed. + const showContent = !isDone || expanded + return ( +
+ {isDone ? ( +
setExpanded((v: boolean) => !v)} + className="flex cursor-pointer items-center gap-1.5 text-sm text-msa-text-2" + > + + {header} + +
+ ) : ( +
+ + {header} +
+ )} +
+
+
+ {text} +
+
+
+
+ ) +} diff --git a/webui/frontend/app/components/messages/ToolBatch.tsx b/webui/frontend/app/components/messages/ToolBatch.tsx new file mode 100644 index 000000000..8ebcbc412 --- /dev/null +++ b/webui/frontend/app/components/messages/ToolBatch.tsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'react' +import { useT } from '~/lib/i18n' +import type { AgentPart } from '~/lib/agentProvider' +import { StepCard } from './StepCard' +import type { OnOpenStep, OnOpenFile } from './types' +import TodoIcon from '~/assets/icons/todo.svg?react' +import ArrowDownIcon from '~/assets/icons/arrow-down.svg?react' + +type StepPart = Extract + +/** + * Outer accordion wrapping one server tool-call round (1..N consecutive tool + * steps): header reads "used N tools", the body holds the individual step + * cards (their own UI/accordions unchanged). + * + * Expansion mirrors the ThoughtsFlow convention: open while it's the + * message's LAST meaningful block (the in-flight round streams visibly), + * auto-collapses once newer parts arrive; a pending authorization inside + * pins it open (the approve/reject buttons must stay reachable). + */ +export function ToolBatch({ + steps, + isLast, + onOpenStep, + onOpenFile +}: { + steps: StepPart[] + isLast: boolean + onOpenStep?: OnOpenStep + onOpenFile?: OnOpenFile +}) { + const { t } = useT() + const hasPending = steps.some( + (p) => String(p.step.meta.state ?? '') === 'pending' + ) + const [expanded, setExpanded] = useState(isLast || hasPending) + + useEffect(() => { + if (!isLast && !hasPending) setExpanded(false) + }, [isLast, hasPending]) + + return ( +
+
setExpanded((v) => !v)} + className="flex cursor-pointer items-center gap-1.5 text-sm font-medium text-msa-text-2" + > + + {t.chat.useTools.replace('{n}', String(steps.length))} + +
+
+
+
+ {steps.map((p, i) => ( + + ))} +
+
+
+
+ ) +} diff --git a/webui/frontend/app/components/messages/TurnPlan.tsx b/webui/frontend/app/components/messages/TurnPlan.tsx new file mode 100644 index 000000000..962c348b6 --- /dev/null +++ b/webui/frontend/app/components/messages/TurnPlan.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from 'react' +import { api } from '~/lib/api' +import type { AgentTask, TaskStatus } from '~/lib/agentProvider' +import { TaskPlan } from './TaskPlan' + +/** Narrow the server's plan status strings (already frontend-mapped by + * sessions._plan_part) into the AgentTask status union. */ +function toAgentTasks( + tasks: { id: string; label: string; status: string }[] +): AgentTask[] { + return tasks.map((t) => ({ + id: t.id, + label: t.label, + status: (['done', 'running', 'pending'].includes(t.status) + ? t.status + : 'pending') as TaskStatus + })) +} + +/** + * Final plan recap at the end of a finished turn: when the loop rewrote the + * todo list (reserved "plan.md" changed_files entry / `plan_file`), the plan + * is shown ONE more time here as the flat TaskPlan accordion — same widget the + * conversation uses inline — instead of a file card. The plan lives in the + * SESSION dir, not the workspace, so it never goes through the file card / + * exists-check. + * + * Scope is PER-TURN: `tasks` is THIS turn's final plan snapshot (the last + * `tasks` part on the message). It is rendered directly so an old turn's recap + * shows the plan as it was at THAT turn — never a later turn's edits. Only a + * render-only turn (todo_render_md without a todo_write → no snapshot) leaves + * `tasks` undefined; then it falls back to the session's current plan via + * GET /sessions/{id}/plan. Renders nothing when there's neither. + */ +export function TurnPlan({ + tasks, + sessionId +}: { + /** This turn's final plan snapshot; rendered directly when present. */ + tasks?: AgentTask[] + /** Fallback source for a render-only turn (no snapshot). */ + sessionId?: string +}) { + const [fetched, setFetched] = useState(null) + const hasSnapshot = tasks !== undefined + + useEffect(() => { + // Only fetch as a fallback: a per-turn snapshot needs no request. + if (hasSnapshot || !sessionId) return + let cancelled = false + api + .getSessionPlan(sessionId, { silent: true }) + .then((plan) => { + if (!cancelled) setFetched(toAgentTasks(plan.tasks)) + }) + .catch(() => { + if (!cancelled) setFetched([]) + }) + return () => { + cancelled = true + } + }, [hasSnapshot, sessionId]) + + const plan = hasSnapshot ? tasks : fetched + if (!plan || plan.length === 0) return null + + // isLast → the recap opens expanded (it closes the message); a frozen + // snapshot, so no live spinner. + return +} diff --git a/webui/frontend/app/components/messages/TurnProcess.tsx b/webui/frontend/app/components/messages/TurnProcess.tsx new file mode 100644 index 000000000..c8010417f --- /dev/null +++ b/webui/frontend/app/components/messages/TurnProcess.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from 'react' +import { useT } from '~/lib/i18n' +import TaskIcon from '~/assets/icons/task.svg?react' +import ArrowDownIcon from '~/assets/icons/arrow-down.svg?react' + +/** Format elapsed seconds as "Ns" (< 60s) or "Nm Ns" (per the design). */ +function formatDuration(seconds: number): string { + const s = Math.max(0, Math.floor(seconds)) + if (s < 60) return `${s}s` + return `${Math.floor(s / 60)}m ${s % 60}s` +} + +/** + * Turn-level process wrapper: everything a turn produced BEFORE its final + * summary answer lives under one header. + * + * - While the turn runs (`done === false`) the header reads "processing Ns ..." + * with a live counter and the blocks below stay FLAT — no chevron, not + * collapsible (the user watches the work happen). + * - Once the SDK closes the tool-call loop (`loop_end` → the turn's final + * assistant text becomes the summary) the header flips to "processed Ns", + * becomes a collapsible accordion (collapsed by default) and its content is + * wrapped in a bordered card; the summary renders after it, outside. + * - An interrupted turn never gets a summary, so it keeps the flat + * "processing" presentation with its frozen elapsed time. + */ +export function TurnProcess({ + done, + live, + startedAt, + durationMs, + children +}: { + /** Loop finished → collapsible "processed" header + bordered content. */ + done: boolean + /** Turn still streaming → tick the counter. */ + live: boolean + /** Epoch ms of the turn's first frame (live counter base). */ + startedAt?: number + /** Server-reported loop duration; preferred once done. */ + durationMs?: number + children: React.ReactNode +}) { + const { t } = useT() + const [expanded, setExpanded] = useState(false) + + // Live elapsed seconds, ticked once a second while the turn is in flight. + // FLOOR (not round) so the number matches wall-clock reading and formatDuration + // — rounding showed "1s" at 0.5s and could tick backwards on timer drift. + const [elapsed, setElapsed] = useState(() => + startedAt ? Math.max(0, Math.floor((Date.now() - startedAt) / 1000)) : 0 + ) + useEffect(() => { + if (!live || !startedAt) return + const tick = () => + setElapsed(Math.max(0, Math.floor((Date.now() - startedAt) / 1000))) + tick() + const id = setInterval(tick, 1000) + return () => clearInterval(id) + }, [live, startedAt]) + + // Prefer the server's loop duration once the turn is done (authoritative and + // identical across live / replay); fall back to the live tick. `elapsed` is + // rendered even at 0 — hiding it for the first second made the header text + // change width one tick later, which read as a jitter. + const seconds = durationMs != null ? Math.floor(durationMs / 1000) : elapsed + const timing = ` ${formatDuration(seconds)}` + + if (!done) { + return ( +
+
+ + + {t.chat.processing} + {timing} + {/* The trailing ellipsis means "still running" — an interrupted turn + keeps the "processing" wording but its clock has stopped. */} + {live ? ' ...' : ''} + +
+ {children} +
+ ) + } + + return ( +
+
setExpanded((v) => !v)} + className="flex w-fit cursor-pointer items-center gap-1.5 text-md text-msa-text-3" + > + + {t.chat.processed} + {timing} + + +
+
+
+ {/* Expanded process history gets its own bordered card (design). */} +
+ {children} +
+
+
+
+ ) +} diff --git a/webui/frontend/app/components/messages/UserBubble.tsx b/webui/frontend/app/components/messages/UserBubble.tsx new file mode 100644 index 000000000..ea103f2c2 --- /dev/null +++ b/webui/frontend/app/components/messages/UserBubble.tsx @@ -0,0 +1,95 @@ +import { FileCard } from '~/components/common/FileCard' +import type { AgentMessage } from '~/lib/agentProvider' +import type { OnOpenFile } from '~/components/messages/types' +import { useT } from '~/lib/i18n' +import { useWorkspaceFileSet } from '~/lib/workspaceFiles' + +/** User message bubble content (right-aligned, preserves line breaks). Any + * files the user attached this turn render as cards above the text; media use + * the workspace raw URL for an inline preview. Non-deleted cards are clickable + * and open the file in the workspace rail. On history replay a file whose + * workspace entry has since been deleted (`exists === false`) falls back to a + * generic card with a "deleted" note and is not clickable. */ +export function UserBubble({ + message, + onOpenFile +}: { + message: AgentMessage + onOpenFile?: OnOpenFile +}) { + const { t } = useT() + const files = message.files ?? [] + // Live workspace path set: deletions/renames/creations flip the cards' + // deleted state immediately (fallback: the server-baked `exists` flag). + const fileSet = useWorkspaceFileSet() + return ( +
+ {files.length > 0 && ( +
+ {files.map((f) => { + const deleted = fileSet + ? !fileSet.has(f.path) + : f.exists === false + const card = ( + + ) + if (deleted || !onOpenFile) { + return ( +
+ {card} +
+ ) + } + return ( +
onOpenFile(f.path)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onOpenFile(f.path) + } + }} + className="group/filecard cursor-pointer rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-msa-line-2" + > + {card} +
+ ) + })} +
+ )} + {message.segments && message.segments.length > 0 ? ( +
+ {message.segments.map((seg, i) => + seg.type === 'skill' ? ( + + /{seg.name || seg.id} + + ) : ( + {seg.text} + ) + )} +
+ ) : ( + message.content && ( +
+ {message.content} +
+ ) + )} +
+ ) +} diff --git a/webui/frontend/app/components/messages/searchResults.ts b/webui/frontend/app/components/messages/searchResults.ts new file mode 100644 index 000000000..7aa8e27c0 --- /dev/null +++ b/webui/frontend/app/components/messages/searchResults.ts @@ -0,0 +1,52 @@ +/** Parsed web_search result item (exa/unified `{results:[...]}` shape). */ +export interface WebSearchResult { + url: string + title: string + summary: string +} + +/** Parse a web_search tool result into displayable items. Returns [] when the + * payload isn't the unified `{results:[...]}` JSON (e.g. still streaming, or + * a grep/glob text blob). Shared by the chat step card (result count + + * favicons) and the right-rail detail (result cards). */ +export function parseWebSearchResults(result: unknown): WebSearchResult[] { + if (typeof result !== 'string' || !result) return [] + let parsed: unknown + try { + parsed = JSON.parse(result) + } catch { + return [] + } + const results = (parsed as { results?: unknown })?.results + if (!Array.isArray(results)) return [] + return results.map((item) => { + const r = + item && typeof item === 'object' && !Array.isArray(item) + ? (item as Record) + : {} + const s = (v: unknown) => (typeof v === 'string' ? v : '') + return { + url: s(r.url), + title: s(r.title), + summary: s(r.summary) || s(r.content) + } + }) +} + +/** Site hostname for display (strips `www.`), '' when the url is invalid. */ +export function hostOf(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, '') + } catch { + return '' + } +} + +/** Public favicon for a result url (Google's s2 service; consumers hide the + * on error so offline/blocked environments degrade gracefully). */ +export function faviconOf(url: string): string { + const host = hostOf(url) + return host + ? `https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=32` + : '' +} diff --git a/webui/frontend/app/components/messages/steps/ArtifactStepCard.tsx b/webui/frontend/app/components/messages/steps/ArtifactStepCard.tsx new file mode 100644 index 000000000..2b60b5a4a --- /dev/null +++ b/webui/frontend/app/components/messages/steps/ArtifactStepCard.tsx @@ -0,0 +1,50 @@ +import { FileTypeIcon, formatFileSize } from '~/components/common/FileCard' +import type { AgentStep } from '~/lib/agentProvider' +import type { OnOpenStep } from '../types' +import JumpIcon from '~/assets/icons/jump.svg?react' + +/** + * File / artifact step card: standard file card look (icon + name + type/size) + * with an optional preview thumbnail. Clicking opens the workspace rail. + */ +export function ArtifactStepCard({ + step, + onOpenStep +}: { + step: AgentStep + onOpenStep?: OnOpenStep +}) { + const meta = step.meta + const name = String(meta.name ?? '') + const fileType = + typeof meta.file_type === 'string' + ? meta.file_type.toUpperCase() + : (name.split('.').pop()?.toUpperCase() ?? 'FILE') + const byte = typeof meta.byte === 'number' ? meta.byte : undefined + const preview = typeof meta.preview === 'string' ? meta.preview : undefined + + return ( + + ) +} diff --git a/webui/frontend/app/components/messages/steps/AuthConfirmStepCard.tsx b/webui/frontend/app/components/messages/steps/AuthConfirmStepCard.tsx new file mode 100644 index 000000000..1da8943b5 --- /dev/null +++ b/webui/frontend/app/components/messages/steps/AuthConfirmStepCard.tsx @@ -0,0 +1,185 @@ +import { Typography } from 'antd' +import { useEffect, useState } from 'react' +import { MsaButton } from '~/components/common/MsaButton' +import { api } from '~/lib/api' +import { useT } from '~/lib/i18n' +import type { AgentStep } from '~/lib/agentProvider' +import { InlineCode } from '../InlineCode' +import AuthorizeIcon from '~/assets/icons/authorize.svg?react' +import ArrowDownIcon from '~/assets/icons/arrow-down.svg?react' + +type AuthState = 'pending' | 'approved' | 'rejected' | 'cancelled' + +/** + * Authorization confirm card: renders as a tool-call accordion showing the + * tool name and parameters, with approve/reject buttons when pending. + * + * The `desc` field from the backend is formatted as "tool_name {args_json}". + * We parse it to show structured UI (same accordion style as ToolCallStepCard). + */ +export function AuthConfirmStepCard({ + step, + isLast +}: { + step: AgentStep + /** Expanded while it's the message's last part; auto-collapses after. */ + isLast?: boolean +}) { + const { t } = useT() + const metaState = (step.meta.state as AuthState) ?? 'pending' + const [localState, setLocalState] = useState(null) + const state = localState ?? metaState + const [busy, setBusy] = useState(false) + const [expanded, setExpanded] = useState( + (isLast ?? false) || metaState === 'pending' + ) + + // Auto-collapse once newer parts arrive — except while pending (buttons + // must stay visible for the user to decide). + useEffect(() => { + if (!isLast && state !== 'pending') setExpanded(false) + }, [isLast, state]) + + const desc = String(step.meta.desc ?? '') + const requestId = String(step.meta.request_id ?? '') + const sessionId = String(step.meta.session_id ?? '') + + // Parse "tool_name {args_json}" from desc + const firstBrace = desc.indexOf('{') + const toolName = firstBrace > 0 ? desc.slice(0, firstBrace).trim() : desc + const argsRaw = firstBrace > 0 ? desc.slice(firstBrace) : '' + let argsFormatted = argsRaw + try { + if (argsRaw) argsFormatted = JSON.stringify(JSON.parse(argsRaw), null, 2) + } catch { + // Keep raw if not valid JSON + } + + const resolve = async (action: 'allow_once' | 'allow_always' | 'deny') => { + const next: AuthState = action === 'deny' ? 'rejected' : 'approved' + if (!requestId || !sessionId) { + setState(next) + return + } + setBusy(true) + try { + // allow_always: the SDK also records the tool into the project's + // permission memory (.ms_agent/permission_memory.json), so future calls + // of the same tool skip the ask entirely. + const { resolved } = await api.resolvePermission({ + session_id: sessionId, + request_id: requestId, + action + }) + setState(resolved ? next : 'rejected') + } catch { + // Global error toast handles it. + } finally { + setBusy(false) + } + } + + const setState = (next: AuthState) => { + setLocalState(next) + // Also write into the part's meta so the streaming layer can see the + // decision: agentProvider merges the tool's RESULT step into this card + // (approved → replace in place; rejected → drop the errored result step). + step.meta.state = next + } + + return ( +
+ {/* Header */} + + + {/* Body: animated accordion */} +
+
+
+ {/* Arguments */} + {argsFormatted && ( +
+
+ {t.chat.detailArguments} +
+
+                  {argsFormatted}
+                
+
+ )} + + {/* Authorization state: deny / always allow (persisted) / allow once */} + {state === 'pending' && ( +
+ resolve('deny')} + > + {t.chat.authReject} + + resolve('allow_always')} + > + {t.chat.authApproveAlways} + + resolve('allow_once')} + > + {t.chat.authApprove} + +
+ )} +
+
+
+
+ ) +} diff --git a/webui/frontend/app/components/messages/steps/TerminalStepCard.tsx b/webui/frontend/app/components/messages/steps/TerminalStepCard.tsx new file mode 100644 index 000000000..f9125b91f --- /dev/null +++ b/webui/frontend/app/components/messages/steps/TerminalStepCard.tsx @@ -0,0 +1,129 @@ +import { useEffect, useState } from 'react' +import { MsaButton } from '~/components/common/MsaButton' +import { api } from '~/lib/api' +import { useT } from '~/lib/i18n' +import type { AgentStep } from '~/lib/agentProvider' +import type { OnOpenStep } from '../types' +import TerminalIcon from '~/assets/icons/terminal.svg?react' +import ArrowDownIcon from '~/assets/icons/arrow-down.svg?react' + +type TerminalState = 'pending' | 'approved' | 'rejected' | 'cancelled' + +/** + * Terminal step card: a collapsible accordion showing a shell command. + * + * When the command requires authorization (`meta.state === 'pending'`), shows + * Reject/Run buttons. Once resolved (or for normal unrestricted commands), the + * code is just displayed in a scrollable area with max height. + */ +export function TerminalStepCard({ + step, + onOpenStep: _onOpenStep, + isLast +}: { + step: AgentStep + onOpenStep?: OnOpenStep + /** Expanded while it's the message's last part; auto-collapses after. */ + isLast?: boolean +}) { + const { t } = useT() + const code = String(step.meta.code ?? '') + const metaState = (step.meta.state as TerminalState | undefined) ?? null + const [localState, setLocalState] = useState(null) + const state = localState ?? metaState + const [busy, setBusy] = useState(false) + const [expanded, setExpanded] = useState( + (isLast ?? false) || metaState === 'pending' + ) + + // Auto-collapse once newer parts arrive — except a pending authorization. + useEffect(() => { + if (!isLast && state !== 'pending') setExpanded(false) + }, [isLast, state]) + + const requestId = String(step.meta.request_id ?? '') + const sessionId = String(step.meta.session_id ?? '') + + const resolve = async (approve: boolean) => { + const next: TerminalState = approve ? 'approved' : 'rejected' + if (!requestId || !sessionId) { + setLocalState(next) + return + } + setBusy(true) + try { + const { resolved } = await api.resolvePermission({ + session_id: sessionId, + request_id: requestId, + action: approve ? 'allow_once' : 'deny' + }) + setLocalState(resolved ? next : 'rejected') + } catch { + // Global api error toast already fired; keep actionable. + } finally { + setBusy(false) + } + } + + return ( +
+ {/* Header: accordion toggle */} + + + {/* Body: animated accordion via grid-template-rows transition */} +
+
+
+
+
+                {code}
+              
+
+ + {/* Authorization: pending → buttons, rejected → label */} + {state === 'pending' && ( +
+ resolve(false)} + > + {t.chat.authReject} + + resolve(true)} + > + {t.chat.authApprove} + +
+ )} + {state === 'rejected' && ( +
+ {t.chat.authRejected} +
+ )} +
+
+
+
+ ) +} diff --git a/webui/frontend/app/components/messages/steps/ToolCallStepCard.tsx b/webui/frontend/app/components/messages/steps/ToolCallStepCard.tsx new file mode 100644 index 000000000..8a7b20021 --- /dev/null +++ b/webui/frontend/app/components/messages/steps/ToolCallStepCard.tsx @@ -0,0 +1,221 @@ +import { Typography } from 'antd' +import { useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import { MsaButton } from '~/components/common/MsaButton' +import { api } from '~/lib/api' +import { useT } from '~/lib/i18n' +import type { AgentStep } from '~/lib/agentProvider' +import type { OnOpenStep } from '../types' +import { InlineCode } from '../InlineCode' +import InvokeIcon from '~/assets/icons/invoke.svg?react' +import ArrowDownIcon from '~/assets/icons/arrow-down.svg?react' + +type ToolState = 'pending' | 'approved' | 'rejected' | 'cancelled' + +/** + * Tool-call step card: a collapsible accordion showing tool name, arguments, + * and result. Supports authorization flow (approve/reject) when the tool + * requires permission (`meta.state === 'pending'`). + */ +export function ToolCallStepCard({ + step, + onOpenStep: _onOpenStep, + isLast, + icon, + title, + titleText +}: { + step: AgentStep + onOpenStep?: OnOpenStep + /** Expanded while it's the message's last part; auto-collapses after. */ + isLast?: boolean + /** Header icon override (defaults to the generic invoke icon). */ + icon?: ReactNode + /** Header title override (defaults to "调用 {tool name}"). */ + title?: ReactNode + /** Plain-text mirror of `title` for the overflow tooltip (rich nodes like + * InlineCode read badly on the dark tooltip background). */ + titleText?: string +}) { + const { t } = useT() + const meta = step.meta + const name = String(meta.tool ?? meta.name ?? '') + const args = meta.arguments + const argsStr = + args && typeof args === 'object' + ? JSON.stringify(args, null, 2) + : String(args ?? '') + const result = String(meta.result ?? '') + + const metaState = (meta.state as ToolState | undefined) ?? null + const [localState, setLocalState] = useState(null) + const state = localState ?? metaState + const [busy, setBusy] = useState(false) + const [expanded, setExpanded] = useState( + (isLast ?? false) || metaState === 'pending' + ) + + // Auto-collapse once newer parts arrive (mirrors ThoughtsFlow) — except a + // pending authorization, whose buttons must stay visible. + useEffect(() => { + if (!isLast && state !== 'pending') setExpanded(false) + }, [isLast, state]) + + const requestId = String(meta.request_id ?? '') + const sessionId = String(meta.session_id ?? '') + + // A denied call (live rejection, or history replay where the errored result + // reads "Tool call denied") shows only the arguments — the denial itself is + // conveyed by the header badge, not a useless result block. + const denied = + state === 'rejected' || + (meta.status === 'error' && /denied/i.test(String(meta.error ?? result))) + // A genuinely failed call (execution error / interruption, not a denial): + // badge in the header + the error rendered in the danger tone. + const failed = meta.status === 'error' && !denied + const errorText = String(meta.error ?? result ?? '') + + const resolve = async (approve: boolean) => { + const next: ToolState = approve ? 'approved' : 'rejected' + if (!requestId || !sessionId) { + setLocalState(next) + return + } + setBusy(true) + try { + const { resolved } = await api.resolvePermission({ + session_id: sessionId, + request_id: requestId, + action: approve ? 'allow_once' : 'deny' + }) + setLocalState(resolved ? next : 'rejected') + } catch { + // Global error toast handles it. + } finally { + setBusy(false) + } + } + + return ( +
+ {/* Header */} + + + {/* Body: animated accordion */} +
+
+
+ {/* Arguments */} + {argsStr && ( +
+
+ {t.chat.detailArguments} +
+
+                  {argsStr}
+                
+
+ )} + + {/* Result: success → normal tone; failure → error tone with the + error text; denied → hidden entirely. */} + {failed ? ( +
+
+ {t.chat.detailError} +
+
+                  {errorText}
+                
+
+ ) : ( + result && + state !== 'pending' && + !denied && ( +
+
+ {t.chat.detailResult} +
+
+                    {result}
+                  
+
+ ) + )} + + {/* Authorization: pending → buttons, rejected → label */} + {state === 'pending' && ( +
+ resolve(false)} + > + {t.chat.authReject} + + resolve(true)} + > + {t.chat.authApprove} + +
+ )} +
+
+
+
+ ) +} diff --git a/webui/frontend/app/components/messages/turnSplit.ts b/webui/frontend/app/components/messages/turnSplit.ts new file mode 100644 index 000000000..c7998a574 --- /dev/null +++ b/webui/frontend/app/components/messages/turnSplit.ts @@ -0,0 +1,39 @@ +import type { AgentPart } from '~/lib/agentProvider' + +export type TextPart = Extract + +export interface TurnSplit { + /** The turn was stopped by the user (its parts carry the interrupt marker). */ + interrupted: boolean + /** The tool-call loop closed (SDK `loop_end`) → the turn has a final summary + * and its process history folds into a collapsible card. An interrupted turn + * never reaches this state: it keeps the flat "processing" presentation. */ + loopDone: boolean + /** Everything produced BEFORE the summary (thoughts, texts, tool rounds). */ + processParts: AgentPart[] + /** The turn's final answer: its trailing text block, once the loop is done. */ + summary: TextPart | null +} + +/** + * Split one assistant turn into its process history and its final summary. + * + * The summary is the turn's TRAILING text block — the reply the tool-call loop + * ended on (`loop_end` fires right after it). It is only split out once the + * loop is done: while the turn streams, and forever for an interrupted turn, + * every block stays "process" so nothing is hidden behind a header for work + * that never concluded. + */ +export function splitTurn(parts: AgentPart[], streaming: boolean): TurnSplit { + const interrupted = parts.some((p) => p.kind === 'interrupted') + const loopDone = !streaming && !interrupted + const last = parts[parts.length - 1] + const hasSummary = + loopDone && last != null && last.kind === 'text' && !!last.text + return { + interrupted, + loopDone, + processParts: hasSummary ? parts.slice(0, parts.length - 1) : parts, + summary: hasSummary ? (last as TextPart) : null + } +} diff --git a/webui/frontend/app/components/messages/types.ts b/webui/frontend/app/components/messages/types.ts new file mode 100644 index 000000000..08a8bd7ee --- /dev/null +++ b/webui/frontend/app/components/messages/types.ts @@ -0,0 +1,20 @@ +import type { AgentStep, AgentTask, StepKind } from '~/lib/agentProvider' + +/** + * Reference to an artifact/step the host can open in the workspace rail. + * Kept here (was previously in ChatPanel) so message components and the + * artifact panel share one import path. + */ +export interface ArtifactRef { + name: string + meta: Record +} + +/** Callback fired when a step card is clicked (opens the workspace rail). */ +export type OnOpenStep = (step: AgentStep) => void + +/** Callback fired when a user-attached file card is clicked: opens the + * workspace rail and selects the given workspace-relative path. */ +export type OnOpenFile = (path: string) => void + +export type { AgentStep, AgentTask, StepKind } diff --git a/webui/frontend/app/components/models/AddProviderModal.tsx b/webui/frontend/app/components/models/AddProviderModal.tsx new file mode 100644 index 000000000..845af3975 --- /dev/null +++ b/webui/frontend/app/components/models/AddProviderModal.tsx @@ -0,0 +1,199 @@ +import { App, Form, Input, Modal, Select, Typography } from 'antd' +import { useEffect, useState } from 'react' +import { CodeEditor } from '~/components/common/CodeEditor' +import { api } from '~/lib/api' +import { useT } from '~/lib/i18n' +import type { Protocol, Provider } from '~/lib/types' + +interface Props { + open: boolean + /** Existing provider to edit, or null/undefined for create mode. */ + provider?: Provider | null + onClose: () => void + onSaved: (provider: Provider) => void +} + +interface FormValues { + id: string + name: string + base_url: string + protocol: Protocol + api_key?: string +} + +export function AddProviderModal({ open, provider, onClose, onSaved }: Props) { + const { t } = useT() + const { message } = App.useApp() + const [form] = Form.useForm() + const [advancedJson, setAdvancedJson] = useState('{}') + const [submitting, setSubmitting] = useState(false) + + const isEdit = !!provider + + useEffect(() => { + if (!open) return + if (provider) { + form.setFieldsValue({ + id: provider.id, + name: provider.name, + base_url: provider.base_url, + protocol: provider.protocol, + api_key: '' + }) + setAdvancedJson( + JSON.stringify(provider.default_generation_params ?? {}, null, 2) + ) + } else { + form.setFieldsValue({ + id: '', + name: '', + base_url: '', + protocol: 'openai', + api_key: '' + }) + setAdvancedJson('{}') + } + }, [open, provider, form]) + + const submit = async () => { + const v = await form.validateFields() + let advanced: Record = {} + try { + const parsed = JSON.parse(advancedJson || '{}') + if (typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Must be a JSON object') + } + advanced = parsed + } catch (e) { + message.error(`${t.resources.jsonInvalid} (${(e as Error).message})`) + return + } + setSubmitting(true) + try { + let saved: Provider + if (provider) { + // Edit: PATCH the provider. Blank api_key keeps the existing key. + saved = await api.updateProvider(provider.id, { + name: v.name, + base_url: v.base_url, + protocol: v.protocol, + default_generation_params: advanced, + ...(v.api_key ? { api_key: v.api_key } : {}) + }) + } else { + saved = await api.createProvider({ + id: v.id, + name: v.name, + base_url: v.base_url, + protocol: v.protocol, + default_generation_params: advanced + }) + // Custom providers are created with no API key. If the user typed one + // in the optional field, push it as a follow-up update so it lands on + // the mask. + if (v.api_key) { + saved = await api.updateProvider(saved.id, { api_key: v.api_key }) + } + } + onSaved(saved) + } catch { + // API errors surface via the global toast (see root ApiErrorBridge). + } finally { + setSubmitting(false) + } + } + + return ( + +
+ + {t.modelsAdmin.providerName}{' '} + * + + } + name="id" + rules={[{ required: true, pattern: /^[a-z0-9][a-z0-9_-]{0,40}$/ }]} + extra={ + + {t.modelsAdmin.providerIdHint} + + } + > + + + + {t.modelsAdmin.displayName}{' '} + * + + } + name="name" + rules={[{ required: true, max: 80 }]} + > + + + + + + + {t.modelsAdmin.protocol} * + + } + name="protocol" + rules={[{ required: true }]} + > + ({ + value: p.id, + label: p.name, + disabled: !p.enabled + }))} + /> + + + {t.modelsAdmin.modelName} * + + } + name="name" + rules={[{ required: true, max: 160 }]} + extra={ + + {t.modelsAdmin.modelNameHint} + + } + > + {isEdit ? ( + + ) : ( + ({ value: id }))} + placeholder={t.modelsAdmin.modelNamePlaceholder} + filterOption={(input, option) => + (option?.value ?? '') + .toString() + .toLowerCase() + .includes(input.toLowerCase()) + } + notFoundContent={ + loadingModels ? t.modelsAdmin.modelsLoading : null + } + /> + )} + + + + + +
+ +
+ + {t.modelsAdmin.generationParamsHint} + +
+
+
+ ) +} diff --git a/webui/frontend/app/components/project/McpTabPanel.tsx b/webui/frontend/app/components/project/McpTabPanel.tsx new file mode 100644 index 000000000..66827f321 --- /dev/null +++ b/webui/frontend/app/components/project/McpTabPanel.tsx @@ -0,0 +1,189 @@ +import { Pagination, Segmented } from 'antd' +import { App } from 'antd' +import { useEffect, useState } from 'react' +import { useSearchParams } from 'react-router' +import { CardSkeletonGrid } from '~/components/common/CardSkeletonGrid' +import { EmptyState } from '~/components/common/EmptyState' +import { MsaButton } from '~/components/common/MsaButton' +import { api } from '~/lib/api' +import { dispatchMcpSkillChanged } from '~/lib/events' +import { useT } from '~/lib/i18n' +import type { Mcp, Project, Scope } from '~/lib/types' +import { McpCard } from '~/components/resources/McpCard' +import { McpCustomModal } from '~/components/resources/McpCustomModal' +import { McpJsonView } from '~/components/resources/McpJsonView' +import AddIcon from '~/assets/icons/add.svg?react' + +// ---------- Main component ---------- + +interface Props { + project: Project +} + +type ImportSource = 'custom' | null + +export function McpTabPanel({ project }: Props) { + const { t } = useT() + const { message } = App.useApp() + const projectScope: Scope = `project:${project.id}` + // Scope lives in the URL (?scope=global|project, next to ?tab=) so a reload + // lands back on the same sub-view. Shared with the Skills tab by design. + const [searchParams, setSearchParams] = useSearchParams() + const activeScope: Scope = + searchParams.get('scope') === 'project' ? projectScope : 'global' + const setActiveScope = (v: Scope) => + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev) + next.set('scope', v === 'global' ? 'global' : 'project') + return next + }, + { replace: true } + ) + const [items, setItems] = useState(null) + const [importing, setImporting] = useState(null) + const [editingMcp, setEditingMcp] = useState(null) + const [viaJson, setViaJson] = useState(false) + const [page, setPage] = useState(1) + + const PAGE_SIZE = 10 + + const refresh = () => + api + .listMcps(activeScope) + .then(setItems) + .catch(() => setItems([])) + + const refreshAndNotify = () => { + refresh() + dispatchMcpSkillChanged() + } + useEffect(() => { + refresh() + setPage(1) + }, [activeScope]) + + // Reset state when project changes (the scope itself is URL-driven; a + // cross-project navigation carries no ?scope, which already means global). + useEffect(() => { + setViaJson(false) + setPage(1) + }, [project.id]) + + const scopeOptions: { value: Scope; label: string }[] = [ + { value: 'global', label: t.resources.globalMcps }, + { value: projectScope, label: t.resources.projectMcps } + ] + + return ( +
+ {/* Toolbar */} +
+ + value={activeScope} + onChange={setActiveScope} + options={scopeOptions} + /> +
+ setViaJson(true)} + className={viaJson ? '!text-msa-text-brand1' : ''} + > + {t.resources.viaJson} + + } + disabled={viaJson} + onClick={() => setImporting('custom')} + > + {t.resources.addMcp} + +
+
+ + {/* Content */} +
+ {viaJson ? ( + setViaJson(false)} + /> + ) : items === null ? ( + + ) : items.length === 0 ? ( + + ) : ( + <> +
+ {items + .slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) + .map((m) => ( + { + await api.updateMcp(m.id, { enabled: v }) + refreshAndNotify() + }} + onReconnect={async () => { + try { + const result = await api.checkMcpHealth(m.id) + if (result.healthy) { + message.success(`${m.name}: ${t.resources.statusOk}`) + } else { + message.error(`${m.name}: ${result.error || t.resources.statusError}`) + } + } catch { + message.error(`${m.name}: ${t.resources.statusError}`) + } + }} + onEdit={() => setEditingMcp(m)} + onRemove={async () => { + await api.deleteMcp(m.id) + refreshAndNotify() + }} + /> + ))} +
+ {items.length > PAGE_SIZE && ( +
+ +
+ )} + + )} +
+ + {/* Modals */} + { + setImporting(null) + setEditingMcp(null) + }} + onSaved={() => { + setImporting(null) + setEditingMcp(null) + refreshAndNotify() + }} + /> +
+ ) +} diff --git a/webui/frontend/app/components/project/NewProjectModal.tsx b/webui/frontend/app/components/project/NewProjectModal.tsx new file mode 100644 index 000000000..0213c04f2 --- /dev/null +++ b/webui/frontend/app/components/project/NewProjectModal.tsx @@ -0,0 +1,349 @@ +import { Button, Form, Input, Modal, Tooltip } from 'antd' +import type { UploadFile } from 'antd' +import { useEffect, useRef, useState } from 'react' +import { api } from '~/lib/api' +import { dispatchWorkspaceChanged } from '~/lib/events' +import { useT } from '~/lib/i18n' +import type { AgentSettings, Project } from '~/lib/types' +import UploadIcon from '~/assets/icons/upload.svg?react' +import { MsaSwitch } from '../common/MsaSwitch' +import { MsaButton } from '../common/MsaButton' +import CloseIcon from '~/assets/icons/close.svg?react' +import { FileTypeIcon } from '~/components/common/FileCard' + +interface Props { + open: boolean + project?: Project + onClose: () => void + onCreated: (project: Project) => void + onUpdated?: (project: Project) => void +} + +interface FormValues { + name: string + instructions: string + local_path: string +} + +export function NewProjectModal({ + open, + project, + onClose, + onCreated, + onUpdated +}: Props) { + const { t } = useT() + const [form] = Form.useForm() + const [settings, setSettings] = useState(null) + const [memoryEnabled, setMemoryEnabled] = useState(true) + const [files, setFiles] = useState([]) + const [submitting, setSubmitting] = useState(false) + const fileInputRef = useRef(null) + const folderInputRef = useRef(null) + // Callback ref: set webkitdirectory the moment the input appears in the DOM + // (antd Modal renders content lazily via portal, so a one-time useEffect on + // mount misses the timing). + const folderRef = (el: HTMLInputElement | null) => { + folderInputRef.current = el + if (el) { + el.setAttribute('webkitdirectory', '') + el.setAttribute('directory', '') + } + } + + const isEditing = !!project + + useEffect(() => { + if (!open) return + if (isEditing) { + // Edit mode: load existing project data + setMemoryEnabled(project.memory_enabled ?? true) + setFiles([]) + form.setFieldsValue({ + name: project.name, + instructions: '', + local_path: project.local_path ?? '' + }) + // Load existing instruction + api + .getInstruction(`project:${project.id}`) + .then((ins) => { + form.setFieldsValue({ instructions: ins.content ?? '' }) + }) + .catch(() => { + // No instruction saved yet + }) + } else { + // Create mode: reset form with default settings + api.getAgentSettings().then((s) => { + setSettings(s) + setMemoryEnabled(s.default_memory_enabled) + setFiles([]) + form.setFieldsValue({ name: '', instructions: '', local_path: '' }) + }) + } + }, [open, project, form, isEditing]) + + const submit = async () => { + const v = await form.validateFields() + setSubmitting(true) + try { + if (isEditing) { + // Update existing project + const updated = await api.updateProject(project.id, { + name: v.name, + local_path: v.local_path, + memory_enabled: memoryEnabled + }) + await api.putInstruction(`project:${project.id}`, v.instructions.trim()) + if (files.length > 0) { + const uploads = files + .filter((f) => f.originFileObj) + .map((f) => + api + .uploadWorkspaceFile( + project.id, + f.originFileObj!, + f.originFileObj!.webkitRelativePath || f.name + ) + .catch(() => {}) + ) + await Promise.all(uploads) + dispatchWorkspaceChanged() + } + form.resetFields() + onUpdated?.(updated) + } else { + // Create new project + const created = await api.createProject({ + name: v.name, + local_path: v.local_path, + memory_enabled: memoryEnabled, + memory_backend: settings?.default_memory_backend ?? 'file' + }) + if (v.instructions.trim()) { + await api.putInstruction(`project:${created.id}`, v.instructions) + } + if (files.length > 0) { + const uploads = files + .filter((f) => f.originFileObj) + .map((f) => + api + .uploadWorkspaceFile( + created.id, + f.originFileObj!, + f.originFileObj!.webkitRelativePath || f.name + ) + .catch(() => {}) + ) + await Promise.all(uploads) + dispatchWorkspaceChanged() + } + form.resetFields() + onCreated(created) + } + } finally { + setSubmitting(false) + } + } + + return ( + +
+ + + + + + + + + + {/* Unified drop zone: accepts file & folder drops + two pick buttons */} +
fileInputRef.current?.click()} + onDragOver={(e) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'copy' + }} + onDrop={async (e) => { + e.preventDefault() + e.stopPropagation() + // Use the FileSystem API to traverse dropped folders and extract + // individual files with their relative paths preserved. + const entries: { file: File; path: string }[] = [] + const readEntry = async ( + entry: FileSystemEntry, + basePath: string + ) => { + if (entry.isFile) { + const file = await new Promise((resolve) => + (entry as FileSystemFileEntry).file(resolve) + ) + entries.push({ file, path: basePath + file.name }) + } else if (entry.isDirectory) { + const reader = ( + entry as FileSystemDirectoryEntry + ).createReader() + let batch: FileSystemEntry[] = [] + // readEntries is chunked; loop until empty. + do { + batch = await new Promise((resolve) => + reader.readEntries(resolve) + ) + for (const child of batch) + await readEntry(child, basePath + entry.name + '/') + } while (batch.length > 0) + } + } + const items = Array.from(e.dataTransfer.items) + for (const item of items) { + if (item.kind !== 'file') continue + const entry = item.webkitGetAsEntry?.() + if (entry) { + await readEntry(entry, '') + } else { + const f = item.getAsFile() + if (f) entries.push({ file: f, path: f.name }) + } + } + if (entries.length === 0) return + const dropped = entries.map(({ file, path }) => ({ + uid: `${path}-${file.lastModified}-${Math.random()}`, + name: path, + size: file.size, + status: 'done' as const, + originFileObj: file + })) + setFiles((prev) => [...prev, ...(dropped as UploadFile[])]) + }} + > + +

+ {t.newProject.dropZoneTip} +

+
e.stopPropagation()}> + fileInputRef.current?.click()} + > + {t.workspace.uploadFile} + + folderInputRef.current?.click()} + > + {t.workspace.uploadFolder} + +
+
+ {/* Hidden file inputs */} + { + if (!e.target.files) return + const selected = Array.from(e.target.files).map( + (file) => + ({ + uid: `${file.name}-${file.lastModified}-${Math.random()}`, + name: file.webkitRelativePath || file.name, + size: file.size, + status: 'done', + originFileObj: file + }) as UploadFile + ) + setFiles((prev) => [...prev, ...selected]) + e.target.value = '' + }} + /> + { + if (!e.target.files) return + const selected = Array.from(e.target.files).map( + (file) => + ({ + uid: `${file.name}-${file.lastModified}-${Math.random()}`, + name: file.webkitRelativePath || file.name, + size: file.size, + status: 'done', + originFileObj: file + }) as UploadFile + ) + setFiles((prev) => [...prev, ...selected]) + e.target.value = '' + }} + /> + {files.length > 0 && ( +
+ {files.map((f) => ( +
+ + + {f.name} + + +
+ ))} +
+ )} +
+ + + + + + {/* Memory toggle section */} +
+
+ {t.newProject.memoryTitle} +
+
+ + {t.newProject.memoryDesc} + + +
+
+
+
+ ) +} diff --git a/webui/frontend/app/components/project/ProjectOverviewView.css b/webui/frontend/app/components/project/ProjectOverviewView.css new file mode 100644 index 000000000..27a9cf763 --- /dev/null +++ b/webui/frontend/app/components/project/ProjectOverviewView.css @@ -0,0 +1,12 @@ +.pov-tabs-scroll-content .ant-tabs-content-holder { + height: 100%; +} + +.pov-tabs-scroll-content .ant-tabs-content { + height: 100%; + overflow-y: auto; +} + +.pov-table-no-last-border .ant-table-tbody>tr:last-child>td { + border-bottom: 0; +} \ No newline at end of file diff --git a/webui/frontend/app/components/project/ProjectOverviewView.tsx b/webui/frontend/app/components/project/ProjectOverviewView.tsx new file mode 100644 index 000000000..b8e80eda0 --- /dev/null +++ b/webui/frontend/app/components/project/ProjectOverviewView.tsx @@ -0,0 +1,794 @@ +import { BulbOutlined } from '@ant-design/icons' +import './ProjectOverviewView.css' +import { + App, + Button, + ConfigProvider, + Drawer, + Dropdown, + Popconfirm, + Skeleton, + Space, + Table, + Tabs, + Tooltip +} from 'antd' +import type { MenuProps } from 'antd' +import { type ReactNode, useEffect, useRef, useState } from 'react' +import { useNavigate, useSearchParams } from 'react-router' +import { Composer } from '~/components/common/Composer' +import { IconButton } from '~/components/common/IconButton' +import { MsaButton } from '~/components/common/MsaButton' +import { McpTabPanel } from '~/components/project/McpTabPanel' +import { SkillTabPanel } from '~/components/project/SkillTabPanel' +import { ProjectWidgetRail } from '~/components/project/ProjectWidgetRail' +import { api } from '~/lib/api' +import { dispatchWorkspaceChanged, useOnWorkspaceChanged } from '~/lib/events' +import type { ChatFileRef } from '~/lib/agentProvider' +import { downloadWorkspaceAll, downloadWorkspacePath } from '~/lib/download' +import { EmptyState } from '~/components/common/EmptyState' +import { DeferredSkeleton } from '~/components/common/DeferredSkeleton' +import { useT } from '~/lib/i18n' +import type { Project, Session, WorkspaceFile } from '~/lib/types' +import EditIcon from '~/assets/icons/edit.svg?react' +import DetailsIcon from '~/assets/icons/custom-instruction.svg?react' +import RecentChatsIcon from '~/assets/icons/recent-chats.svg?react' +import WorkspaceIcon from '~/assets/icons/workspace.svg?react' +import McpIcon from '~/assets/icons/mcp.svg?react' +import SkillIcon from '~/assets/icons/skill.svg?react' +import JumpIcon from '~/assets/icons/jump.svg?react' +import { FileTypeIcon } from '~/components/common/FileCard' +import ChatsIcon from '~/assets/icons/recent-chats.svg?react' +import MediaIcon from '~/assets/icons/media.svg?react' +import ParamsIcon from '~/assets/icons/params.svg?react' +import TodoIcon from '~/assets/icons/todo.svg?react' +import GlobeIcon from '~/assets/files/web.svg?react' +import TerminalIcon from '~/assets/icons/terminal.svg?react' +import FolderIcon from '~/assets/icons/folder.svg?react' +import DownloadIcon from '~/assets/icons/download.svg?react' +import CaretDownIcon from '~/assets/icons/chevron-down.svg?react' +import RefreshIcon from '~/assets/icons/refresh.svg?react' + +interface Props { + project: Project + sessions: Session[] + onEditProject?: (p: Project) => void +} + +export function ProjectOverviewView({ + project, + sessions, + onEditProject +}: Props) { + const { t } = useT() + const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() + const VALID_TABS = ['recent', 'workspace', 'mcps', 'skills'] as const + const tabFromUrl = searchParams.get('tab') ?? 'recent' + const activeTab = VALID_TABS.includes(tabFromUrl as any) + ? tabFromUrl + : 'recent' + const setActiveTab = (key: string) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev) + if (key === 'recent') next.delete('tab') + else next.set('tab', key) + return next + }, + { replace: true } + ) + } + + const handleSubmit = async (text: string, files?: ChatFileRef[]) => { + const session = await api.createSession({ + title: text.slice(0, 60) || 'New chat', + project_id: project.id, + preview: text + }) + navigate(`/projects/${project.id}/sessions/${session.id}`, { + state: { prefill: text, prefillFiles: files } + }) + } + + // Right widget rail is side-by-side on >=lg; on smaller screens it collapses + // into a drawer opened from a title-bar button (kept out of the horizontal + // flow so narrow screens never squeeze or hide it silently). + const [detailsDrawer, setDetailsDrawer] = useState(false) + + return ( +
+ {/* Main content */} +
+
+ {/* Project title with edit icon */} +
+

+ {project.name} +

+ {onEditProject && ( + } + size="sm" + variant="filled" + onClick={() => onEditProject(project)} + /> + )} + {/* + } + size="sm" + variant="filled" + onClick={() => setDetailsDrawer(true)} + /> + +
+ + {/* Composer */} + + + {/* Tabs: Recent / Workspace / MCPs / Skills */} + + + {activeTab === 'recent' && ( + + )} + {t.projectDetail.tabRecent} + + ), + children: ( + + ) + }, + { + key: 'workspace', + label: ( + + {activeTab === 'workspace' && ( + + )} + {t.projectDetail.tabWorkspace} + + ), + children: + }, + { + key: 'mcps', + label: ( + + {activeTab === 'mcps' && } + {t.projectDetail.tabMcps} + + ), + children: + }, + { + key: 'skills', + label: ( + + {activeTab === 'skills' && ( + + )} + {t.projectDetail.tabSkills} + + ), + children: + } + ]} + /> + +
+
+ + {/* Right widget rail — large screen only, no divider */} +
+ +
+ + {/* setDetailsDrawer(false)} + placement="right" + size="min(720px, 92vw)" + title={t.projectDetail.detailsPanel} + styles={{ body: { padding: 20 } }} + > + + +
+ ) +} + +/* ─── Recent Chats ─────────────────────────────────── */ + +// Topic category -> leading icon for a recent conversation. Keys mirror the +// backend taxonomy (ms_agent/titler.CATEGORIES); an unset/unknown category +// falls back to the generic "general" chat icon. +const CATEGORY_ICON: Record = { + coding: , + writing: , + research: , + planning: , + data: , + creative: , + media: , + general: +} + +function categoryIcon(category?: string): ReactNode { + return CATEGORY_ICON[ + category && category in CATEGORY_ICON ? category : 'general' + ] +} + +function getRelativeTime(dateStr: string, t: any): string { + const now = Date.now() + const then = new Date(dateStr).getTime() + const diff = now - then + const minutes = Math.floor(diff / 60000) + if (minutes < 1) return t.projectDetail.timeJustNow + if (minutes < 60) + return t.projectDetail.timeMinutesAgo.replace('{n}', String(minutes)) + const hours = Math.floor(minutes / 60) + if (hours < 24) + return t.projectDetail.timeHoursAgo.replace('{n}', String(hours)) + const days = Math.floor(hours / 24) + if (days < 30) return t.projectDetail.timeDaysAgo.replace('{n}', String(days)) + return new Date(dateStr).toLocaleDateString() +} + +function RecentChats({ + projectId, + sessions +}: { + projectId: string + sessions: Session[] +}) { + const { t } = useT() + const navigate = useNavigate() + + if (sessions.length === 0) { + return ( + navigate(`/projects/${projectId}/new`)} + > + {t.projectDetail.startChat} + + } + /> + ) + } + + return ( +
+ {sessions.map((s) => ( +
navigate(`/projects/${projectId}/sessions/${s.id}`)} + > + {/* Topic-category icon */} + + {categoryIcon(s.category)} + + {/* Title + preview */} +
+
+ {s.title} +
+ {s.preview && ( +
+ {s.preview} +
+ )} +
+ {/* Relative time / Enter chat */} + + {getRelativeTime(s.updated_at, t)} + + + {t.projectDetail.enterChat} + + +
+ ))} +
+ ) +} + +/* ─── Workspace Panel ──────────────────────────────── */ + +function WorkspacePanel({ project }: { project: Project }) { + // Drives the add-file caret flip (antd Dropdown owns the panel). + const [addMenuOpen, setAddMenuOpen] = useState(false) + const { t } = useT() + const { message } = App.useApp() + const [files, setFiles] = useState(null) + const [currentPath, setCurrentPath] = useState('') + const [downloadingAll, setDownloadingAll] = useState(false) + const fileInputRef = useRef(null) + const folderInputRef = useRef(null) + + // Set webkitdirectory attribute via DOM (React doesn't support it natively) + useEffect(() => { + if (folderInputRef.current) { + folderInputRef.current.setAttribute('webkitdirectory', '') + folderInputRef.current.setAttribute('directory', '') + } + }, []) + + const loadFiles = () => { + api + .listWorkspaceFiles(project.id) + .then(setFiles) + .catch(() => setFiles([])) + } + + useEffect(() => { + loadFiles() + }, [project.id]) + + // Re-fetch when another component uploads/creates files in this workspace. + useOnWorkspaceChanged(loadFiles) + + const handleUpload = async (fileList: FileList | null) => { + if (!fileList || fileList.length === 0) return + // Multipart upload preserves raw bytes, so binary files (images, archives, + // …) aren't corrupted by UTF-8 coercion the way `file.text()` would. + const uploads = Array.from(fileList).map((file) => + api + .uploadWorkspaceFile( + project.id, + file, + currentPath + (file.webkitRelativePath || file.name), + { silent: [409] } + ) + .catch(() => {}) + ) + await Promise.all(uploads) + // Broadcast: refreshes this table (own listener), the workspace rail and + // any chat file cards. + dispatchWorkspaceChanged() + } + + // Zip the whole workspace and download it as `.zip`. + const handleDownloadAll = async () => { + if (!files || files.length === 0) return + setDownloadingAll(true) + try { + await downloadWorkspaceAll( + project.id, + files, + `${project.name || 'workspace'}.zip` + ) + } catch { + message.error(t.workspace.downloadFailed) + } finally { + setDownloadingAll(false) + } + } + + // Download a row: a file streams directly, a folder is zipped automatically. + const handleDownload = async (path: string) => { + try { + await downloadWorkspacePath(project.id, path, files ?? []) + } catch { + message.error(t.workspace.downloadFailed) + } + } + + const addMenu: MenuProps = { + items: [ + { + key: 'upload-file', + label: t.workspace.uploadFile, + onClick: () => fileInputRef.current?.click() + }, + { + key: 'upload-folder', + label: t.workspace.uploadFolder, + onClick: () => folderInputRef.current?.click() + } + ] + } + + // Compute visible files at current path level + // Include explicit items + virtual folders derived from nested paths + const visibleFiles = (() => { + const allFiles = files ?? [] + const directItems: WorkspaceFile[] = [] + const virtualFolderNames = new Set() + + for (const f of allFiles) { + if (!f.path.startsWith(currentPath)) continue + const relativePath = f.path.slice(currentPath.length) + if (!relativePath) continue + if (!relativePath.includes('/')) { + // Direct child + directItems.push(f) + } else { + // Nested — extract the first segment as a virtual folder + const folderName = relativePath.split('/')[0] + virtualFolderNames.add(folderName) + } + } + + // Add virtual folders that don't already have an explicit folder entry + const existingNames = new Set( + directItems.map((f) => f.path.slice(currentPath.length)) + ) + for (const name of virtualFolderNames) { + if (!existingNames.has(name)) { + directItems.push({ + project_id: project.id, + path: currentPath + name, + kind: 'folder', + size: 0, + updated_at: new Date().toISOString() + }) + } + } + + // Sort: folders first, then files; within each group sort by name alphabetically + directItems.sort((a, b) => { + const aIsFolder = a.kind === 'folder' ? 0 : 1 + const bIsFolder = b.kind === 'folder' ? 0 : 1 + if (aIsFolder !== bIsFolder) return aIsFolder - bIsFolder + const aName = a.path.slice(currentPath.length).toLowerCase() + const bName = b.path.slice(currentPath.length).toLowerCase() + return aName.localeCompare(bName) + }) + + return directItems + })() + + // Find latest updated_at from visible files + const lastEdited = + visibleFiles.length > 0 + ? visibleFiles.reduce((latest, f) => + new Date(f.updated_at) > new Date(latest.updated_at) ? f : latest + ).updated_at + : null + + // Breadcrumb segments + const pathSegments = currentPath ? currentPath.split('/').filter(Boolean) : [] + + const navigateToSegment = (index: number) => { + if (index < 0) { + setCurrentPath('') + } else { + setCurrentPath(pathSegments.slice(0, index + 1).join('/') + '/') + } + } + + const formatDate = (dateStr: string) => { + const d = new Date(dateStr) + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` + } + + const formatSize = (bytes: number) => { + if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)}G` + if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)}M` + if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(1)}K` + return `${bytes}B` + } + + return ( +
+ {/* Hidden file inputs */} + { + handleUpload(e.target.files) + e.target.value = '' + }} + /> + { + handleUpload(e.target.files) + e.target.value = '' + }} + /> + + {files === null ? ( + + {/* Info bar — mirrors the real workspace info bar */} +
+ + +
+ {/* Rows — mirror the file table columns: + icon + name on the left, size · date · actions on the right. */} + {Array.from({ length: 5 }).map((_, i) => ( +
+ + +
+ + + +
+
+ ))} +
+ ) : files.length > 0 ? ( +
+ {/* Info bar */} +
+ + {t.workspace.lastEdited} + {lastEdited && formatDate(lastEdited)} + + + + + + + +
+ + {/* Breadcrumb */} + {currentPath && ( +
+ + {pathSegments.map((seg, i) => ( + + / + {i < pathSegments.length - 1 ? ( + + ) : ( + {seg} + )} + + ))} +
+ )} + + {/* File table */} + + ) + }} + columns={[ + { + dataIndex: 'path', + render: (_: string, record: WorkspaceFile) => { + const displayName = record.path.slice(currentPath.length) + return ( + + {record.kind === 'folder' ? ( + + ) : ( + + )} + {record.kind === 'folder' ? ( + + ) : ( + + {displayName} + + )} + + ) + } + }, + { + dataIndex: 'size', + width: 100, + render: (size: number) => ( + + {formatSize(size)} + + ) + }, + { + dataIndex: 'updated_at', + width: 140, + render: (date: string) => ( + + {getRelativeTime(date, t)} + + ) + }, + { + key: 'actions', + width: 120, + render: (_: unknown, record: WorkspaceFile) => ( + + + { + if (record.kind === 'folder') { + // Delete all files under this folder + const prefix = record.path + '/' + const children = (files ?? []).filter((f) => + f.path.startsWith(prefix) + ) + await Promise.all( + children.map((f) => + api + .deleteWorkspaceFile(project.id, f.path, { + silent: true + }) + .catch(() => {}) + ) + ) + } + await api + .deleteWorkspaceFile(project.id, record.path) + .catch(() => {}) + dispatchWorkspaceChanged() + }} + okText={t.workspace.delete} + cancelText={t.workspace.cancel} + > + + + + ) + } + ]} + /> + + ) : ( +
+ {/* Info bar */} +
+ + + + + + +
+
+ +
+
+ )} + + ) +} diff --git a/webui/frontend/app/components/project/ProjectWidgetRail.tsx b/webui/frontend/app/components/project/ProjectWidgetRail.tsx new file mode 100644 index 000000000..d9eb246fb --- /dev/null +++ b/webui/frontend/app/components/project/ProjectWidgetRail.tsx @@ -0,0 +1,17 @@ +import { InstructionsCard } from '~/components/widgets/InstructionsCard' +import { MemoryCard } from '~/components/widgets/MemoryCard' +import type { Project, Scope } from '~/lib/types' + +interface Props { + project: Project +} + +export function ProjectWidgetRail({ project }: Props) { + const projectScope: Scope = `project:${project.id}` + return ( +
+ + +
+ ) +} diff --git a/webui/frontend/app/components/project/SkillTabPanel.tsx b/webui/frontend/app/components/project/SkillTabPanel.tsx new file mode 100644 index 000000000..7ba62ae45 --- /dev/null +++ b/webui/frontend/app/components/project/SkillTabPanel.tsx @@ -0,0 +1,152 @@ +import { Pagination, Segmented } from 'antd' +import { useEffect, useState } from 'react' +import { useSearchParams } from 'react-router' +import { CardSkeletonGrid } from '~/components/common/CardSkeletonGrid' +import { EmptyState } from '~/components/common/EmptyState' +import { MsaButton } from '~/components/common/MsaButton' +import { api } from '~/lib/api' +import { dispatchMcpSkillChanged } from '~/lib/events' +import { useT } from '~/lib/i18n' +import type { Project, Scope, Skill } from '~/lib/types' +import { SkillCard } from '~/components/resources/SkillCard' +import { SkillsFromLocalModal } from '~/components/resources/SkillsFromLocalModal' +import { SkillDetailDrawer } from '~/components/resources/SkillDetailDrawer' +import AddIcon from '~/assets/icons/add.svg?react' + +// ---------- Main component ---------- + +interface Props { + project: Project +} + +export function SkillTabPanel({ project }: Props) { + const { t } = useT() + const projectScope: Scope = `project:${project.id}` + // Scope lives in the URL (?scope=global|project, next to ?tab=) so a reload + // lands back on the same sub-view. Shared with the MCPs tab by design. + const [searchParams, setSearchParams] = useSearchParams() + const activeScope: Scope = + searchParams.get('scope') === 'project' ? projectScope : 'global' + const setActiveScope = (v: Scope) => + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev) + next.set('scope', v === 'global' ? 'global' : 'project') + return next + }, + { replace: true } + ) + const [items, setItems] = useState(null) + const [showLocal, setShowLocal] = useState(false) + const [detailSkill, setDetailSkill] = useState(null) + const [page, setPage] = useState(1) + + const PAGE_SIZE = 10 + + const refresh = () => + api + .listSkills(activeScope) + .then(setItems) + .catch(() => setItems([])) + + const refreshAndNotify = () => { + refresh() + dispatchMcpSkillChanged() + } + useEffect(() => { + refresh() + setPage(1) + }, [activeScope]) + + // Reset state when project changes (the scope itself is URL-driven). + useEffect(() => { + setPage(1) + }, [project.id]) + + const scopeOptions: { value: Scope; label: string }[] = [ + { value: 'global', label: t.resources.globalSkills }, + { value: projectScope, label: t.resources.projectSkills } + ] + + return ( +
+ {/* Toolbar */} +
+ + value={activeScope} + onChange={setActiveScope} + options={scopeOptions} + /> + } + onClick={() => setShowLocal(true)} + > + {t.resources.addSkill} + +
+ + {/* Content */} +
+ {items === null ? ( + + ) : items.length === 0 ? ( + + ) : ( + <> +
+ {items + .slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) + .map((s) => ( + { + await api.updateSkill(s.id, { enabled: v }) + refreshAndNotify() + }} + onView={() => setDetailSkill(s)} + onRemove={async () => { + await api.deleteSkill(s.id) + refreshAndNotify() + }} + /> + ))} +
+ {items.length > PAGE_SIZE && ( +
+ +
+ )} + + )} +
+ + {/* Upload modal */} + setShowLocal(false)} + onImported={() => { + setShowLocal(false) + refreshAndNotify() + }} + /> + + {/* Detail drawer */} + setDetailSkill(null)} + onSkillChange={setDetailSkill} + /> +
+ ) +} diff --git a/webui/frontend/app/components/resources/McpCard.tsx b/webui/frontend/app/components/resources/McpCard.tsx new file mode 100644 index 000000000..343cf21ae --- /dev/null +++ b/webui/frontend/app/components/resources/McpCard.tsx @@ -0,0 +1,110 @@ +import { Button, Dropdown, Popconfirm, Tooltip } from 'antd' +import type { MenuProps } from 'antd' +import { useState } from 'react' +import { MsaSwitch } from '~/components/common/MsaSwitch' +import { useT } from '~/lib/i18n' +import type { Mcp } from '~/lib/types' +import MoreIcon from '~/assets/icons/more.svg?react' +import RefreshIcon from '~/assets/icons/refresh.svg?react' + +interface McpCardProps { + mcp: Mcp + onToggle: (v: boolean) => void + onReconnect?: () => void + onEdit?: () => void + onRemove: () => void +} + +export function McpCard({ + mcp, + onToggle, + onReconnect, + onEdit, + onRemove +}: McpCardProps) { + const { t } = useT() + const [confirmOpen, setConfirmOpen] = useState(false) + const [testing, setTesting] = useState(false) + + const menu: MenuProps = { + onClick: (e) => e.domEvent.stopPropagation(), + items: [ + ...(onEdit + ? [{ key: 'edit', label: t.resources.edit, onClick: onEdit }] + : []), + { + key: 'remove', + label: t.resources.remove, + danger: true, + onClick: () => setConfirmOpen(true) + } + ] + } + + return ( +
onToggle(!mcp.enabled)} + > +
+ + {mcp.name} + + e.stopPropagation()}> + + +
+
+ + {mcp.description || t.resources.noDescription} + +
e.stopPropagation()} + > + {onReconnect && mcp.transport !== 'stdio' && ( + +
+
+
+ ) +} diff --git a/webui/frontend/app/components/resources/McpCustomModal.tsx b/webui/frontend/app/components/resources/McpCustomModal.tsx new file mode 100644 index 000000000..9267fbf18 --- /dev/null +++ b/webui/frontend/app/components/resources/McpCustomModal.tsx @@ -0,0 +1,208 @@ +import { Form, Input, Modal, Radio, Segmented, message } from 'antd' +import { useEffect, useState } from 'react' +import { CodeEditor } from '~/components/common/CodeEditor' +import { api } from '~/lib/api' +import { useT } from '~/lib/i18n' +import type { Mcp, McpTransport, Scope } from '~/lib/types' +import { fromMcpServers } from './mcpJson' + +interface Props { + open: boolean + scope: Scope + scopeBadge?: string + /** When provided, modal enters edit mode with pre-filled values */ + editingMcp?: Mcp | null + onClose: () => void + onSaved: () => void +} + +const TEMPLATE = `{ + "mcpServers": { + "stdio-server-example": { + "command": "npx", + "args": ["-y", "mcp-server-example"] + }, + "sse-server-example": { + "type": "streamable_http", + "url": "https://example.com/mcp" + } + } +} +` + +type TabMode = 'form' | 'json' + +export function McpCustomModal({ + open, + scope, + scopeBadge, + editingMcp, + onClose, + onSaved +}: Props) { + const { t } = useT() + const isEdit = !!editingMcp + const [tab, setTab] = useState('form') + const [text, setText] = useState('') + const [submitting, setSubmitting] = useState(false) + const [form] = Form.useForm<{ + name: string + transport: McpTransport + endpoint: string + description: string + }>() + + useEffect(() => { + if (open) { + setTab('form') + setText('') + if (editingMcp) { + form.setFieldsValue({ + name: editingMcp.name, + transport: editingMcp.transport, + endpoint: editingMcp.endpoint, + description: editingMcp.description + }) + } else { + form.resetFields() + } + } + }, [open, form, editingMcp]) + + const submitForm = async () => { + const values = await form.validateFields() + setSubmitting(true) + try { + if (isEdit) { + await api.updateMcp(editingMcp!.id, values) + } else { + await api.createMcp({ ...values, enabled: true, scope }) + } + onSaved() + } finally { + setSubmitting(false) + } + } + + const submitJson = async () => { + let parsed + try { + parsed = fromMcpServers(text) + } catch (e) { + message.error(`${t.mcpImport.customInvalid} (${(e as Error).message})`) + return + } + if (parsed.length === 0) { + onClose() + return + } + setSubmitting(true) + try { + for (const m of parsed) { + await api.createMcp({ ...m, scope }) + } + onSaved() + } finally { + setSubmitting(false) + } + } + + const transport = Form.useWatch('transport', form) ?? 'sse' + + return ( + + {t.mcpImport.customTitle} + {scopeBadge && ( + + {scopeBadge} + + )} + + } + okText={isEdit ? t.resources.save : t.mcpImport.customConfirm} + cancelText={t.resources.cancel} + onOk={tab === 'form' ? submitForm : submitJson} + okButtonProps={{ loading: submitting }} + destroyOnHidden + width={620} + > + + value={tab} + onChange={setTab} + options={[ + { value: 'form', label: t.mcpImport.formTab }, + { value: 'json', label: t.mcpImport.jsonTab } + ]} + className="mb-4" + /> + + {tab === 'form' ? ( +
+ + + + + + SSE + StreamableHTTP + STDIO + + + + {transport === 'stdio' ? ( + + ) : ( + + )} + + + + + + ) : ( +
+ {!text && ( +
+              {TEMPLATE}
+            
+ )} + +
+ )} +
+ ) +} diff --git a/webui/frontend/app/components/resources/McpJsonView.tsx b/webui/frontend/app/components/resources/McpJsonView.tsx new file mode 100644 index 000000000..fc49124a1 --- /dev/null +++ b/webui/frontend/app/components/resources/McpJsonView.tsx @@ -0,0 +1,82 @@ +import { Button, message } from 'antd' +import { useEffect, useMemo, useState } from 'react' +import { CodeEditor } from '~/components/common/CodeEditor' +import { api } from '~/lib/api' +import { useT } from '~/lib/i18n' +import type { Mcp, Scope } from '~/lib/types' +import { fromMcpServers, toMcpServers } from './mcpJson' + +interface McpJsonViewProps { + scope: Scope + items: Mcp[] + onSaved: () => void + onCancel: () => void +} + +export function McpJsonView({ + scope, + items, + onSaved, + onCancel +}: McpJsonViewProps) { + const { t } = useT() + const original = useMemo( + () => JSON.stringify(toMcpServers(items), null, 2), + [items] + ) + const [text, setText] = useState(original) + const [saving, setSaving] = useState(false) + + useEffect(() => { + setText(original) + }, [original]) + + const dirty = text !== original + + const save = async () => { + let parsed + try { + parsed = fromMcpServers(text) + } catch (e) { + message.error(`${t.resources.jsonInvalid} (${(e as Error).message})`) + return + } + setSaving(true) + try { + await Promise.all(items.map((m) => api.deleteMcp(m.id))) + for (const m of parsed) { + await api.createMcp({ ...m, scope }) + } + onSaved() + } finally { + setSaving(false) + } + } + + return ( +
+
+ +
+
+ + + +
+
+ ) +} diff --git a/webui/frontend/app/components/resources/McpsPanel.tsx b/webui/frontend/app/components/resources/McpsPanel.tsx new file mode 100644 index 000000000..83da2d9b8 --- /dev/null +++ b/webui/frontend/app/components/resources/McpsPanel.tsx @@ -0,0 +1,135 @@ +import { App, Pagination } from 'antd' +import { useEffect, useState } from 'react' +import { useSearchParams } from 'react-router' +import { CardSkeletonGrid } from '~/components/common/CardSkeletonGrid' +import { EmptyState } from '~/components/common/EmptyState' +import { api } from '~/lib/api' +import { useT } from '~/lib/i18n' +import type { Mcp, Scope } from '~/lib/types' +import { McpCard } from './McpCard' +import { McpCustomModal } from './McpCustomModal' +import { McpJsonView } from './McpJsonView' + +type ImportSource = 'custom' | null + +interface McpsPanelProps { + viaJson?: boolean + onViaJsonChange?: (v: boolean) => void + importing?: ImportSource + onImportingChange?: (v: ImportSource) => void +} + +export function McpsPanel({ + viaJson = false, + onViaJsonChange, + importing: importingProp, + onImportingChange +}: McpsPanelProps) { + const { t } = useT() + const { message } = App.useApp() + const [searchParams] = useSearchParams() + // Scope lives in the URL (?scope=), so it is derived, not mirrored in state. + const activeScope: Scope = + (searchParams.get('scope') as Scope | null) ?? 'global' + const [items, setItems] = useState(null) + const [editingMcp, setEditingMcp] = useState(null) + const [importingInternal, setImportingInternal] = useState(null) + const [page, setPage] = useState(1) + + const PAGE_SIZE = 12 + + const importing = importingProp ?? importingInternal + const setImporting = onImportingChange ?? setImportingInternal + + const refresh = () => api.listMcps(activeScope).then(setItems) + useEffect(() => { + setItems(null) + setPage(1) + refresh() + }, [activeScope]) + + const scopeBadge = + activeScope === 'global' + ? t.mcpImport.hubGlobalBadge + : t.mcpImport.hubProjectBadge + + return ( +
+
+ {viaJson ? ( + onViaJsonChange?.(false)} + /> + ) : items === null ? ( + + ) : items.length === 0 ? ( + + ) : ( + <> +
+ {items + .slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) + .map((m) => ( + { + await api.updateMcp(m.id, { enabled: v }) + refresh() + }} + onReconnect={async () => { + try { + const result = await api.checkMcpHealth(m.id) + if (result.healthy) { + message.success(`${m.name}: ${t.resources.statusOk}`) + } else { + message.error(`${m.name}: ${result.error || t.resources.statusError}`) + } + } catch { + message.error(`${m.name}: ${t.resources.statusError}`) + } + }} + onEdit={() => setEditingMcp(m)} + onRemove={async () => { + await api.deleteMcp(m.id) + refresh() + }} + /> + ))} +
+ {items.length > PAGE_SIZE && ( +
+ +
+ )} + + )} +
+ + { + setImporting(null) + setEditingMcp(null) + }} + onSaved={() => { + setImporting(null) + setEditingMcp(null) + refresh() + }} + /> +
+ ) +} diff --git a/webui/frontend/app/components/resources/SkillCard.tsx b/webui/frontend/app/components/resources/SkillCard.tsx new file mode 100644 index 000000000..b13e95872 --- /dev/null +++ b/webui/frontend/app/components/resources/SkillCard.tsx @@ -0,0 +1,91 @@ +import { Button, Dropdown, Popconfirm, Tooltip } from 'antd' +import type { MenuProps } from 'antd' +import { useMemo, useState } from 'react' +import { MsaSwitch } from '~/components/common/MsaSwitch' +import { useT } from '~/lib/i18n' +import type { Skill } from '~/lib/types' +import MoreIcon from '~/assets/icons/more.svg?react' + +interface SkillCardProps { + skill: Skill + onToggle: (v: boolean) => void + onView?: () => void + onRemove: () => void +} + +export function SkillCard({ + skill, + onToggle, + onView, + onRemove +}: SkillCardProps) { + const { t } = useT() + const [confirmOpen, setConfirmOpen] = useState(false) + + const oneLiner = useMemo(() => { + const firstLine = (skill.content || '') + .split('\n') + .map((s) => s.trim()) + .find((s) => s && !s.startsWith('#')) + return firstLine || '' + }, [skill.content]) + + const menu: MenuProps = { + onClick: (e) => e.domEvent.stopPropagation(), + items: [ + ...(onView + ? [{ key: 'view', label: t.resources.tryIt, onClick: onView }] + : []), + { + key: 'remove', + label: t.resources.remove, + danger: true, + onClick: () => setConfirmOpen(true) + } + ] + } + + return ( +
onToggle(!skill.enabled)} + > +
+ + {skill.name} + + e.stopPropagation()}> + + +
+
+ + {oneLiner || t.resources.noDescription} + + { + setConfirmOpen(false) + onRemove() + }} + onCancel={() => setConfirmOpen(false)} + okText={t.resources.remove} + okButtonProps={{ danger: true }} + > + + +
+
+ ) +} diff --git a/webui/frontend/app/components/resources/SkillDetailDrawer.tsx b/webui/frontend/app/components/resources/SkillDetailDrawer.tsx new file mode 100644 index 000000000..3ff4a1655 --- /dev/null +++ b/webui/frontend/app/components/resources/SkillDetailDrawer.tsx @@ -0,0 +1,252 @@ +import { Drawer, Segmented, Select, Tooltip } from 'antd' +import type { TreeDataNode } from 'antd' +import { useEffect, useMemo, useRef, useState } from 'react' +import { CodeEditor } from '~/components/common/CodeEditor' +import { FolderTree } from '~/components/common/FolderTree' +import { Markdown } from '~/components/common/Markdown' +import { api } from '~/lib/api' +import { useT } from '~/lib/i18n' +import type { Skill } from '~/lib/types' +import ViewIcon from '~/assets/icons/view.svg?react' +import TerminalIcon from '~/assets/icons/terminal.svg?react' + +interface Props { + open: boolean + skill: Skill | null + /** Sibling skills shown in the top-left switcher dropdown. */ + allSkills: Skill[] + onClose: () => void + onSkillChange: (skill: Skill) => void +} + +type ViewMode = 'preview' | 'code' + +const LANGUAGE_BY_EXT: Record = { + md: 'markdown', + markdown: 'markdown', + py: 'python', + ts: 'typescript', + tsx: 'typescript', + js: 'javascript', + jsx: 'javascript', + json: 'json', + sh: 'shell', + bash: 'shell', + yaml: 'yaml', + yml: 'yaml', + svg: 'xml', + xml: 'xml', + html: 'html', + css: 'css', + txt: 'plaintext' +} + +function languageFor(path: string): string { + const ext = path.split('.').pop()?.toLowerCase() ?? '' + return LANGUAGE_BY_EXT[ext] ?? 'plaintext' +} + +/** Build a FolderTree data set from the backend's flat relative-path list. + * Keys follow the FolderTree convention (`dir:` / `file:`) so the + * tree infers icons itself. Directories first, then files, both sorted. */ +function buildTree(paths: string[]): TreeDataNode[] { + interface DirNode { + dirs: Map + files: string[] // full relative paths + } + const root: DirNode = { dirs: new Map(), files: [] } + for (const p of paths) { + const parts = p.split('/') + let cur = root + for (const part of parts.slice(0, -1)) { + let next = cur.dirs.get(part) + if (!next) { + next = { dirs: new Map(), files: [] } + cur.dirs.set(part, next) + } + cur = next + } + cur.files.push(p) + } + const toNodes = (node: DirNode, prefix: string): TreeDataNode[] => { + const dirs = [...node.dirs.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, child]) => { + const full = prefix ? `${prefix}/${name}` : name + return { + key: `dir:${full}`, + title: name, + children: toNodes(child, full) + } as TreeDataNode + }) + const files = [...node.files] + .sort((a, b) => a.localeCompare(b)) + .map( + (p) => + ({ + key: `file:${p}`, + title: p.split('/').pop() ?? p, + isLeaf: true + }) as TreeDataNode + ) + return [...dirs, ...files] + } + return toNodes(root, '') +} + +export function SkillDetailDrawer({ + open, + skill, + allSkills, + onClose, + onSkillChange +}: Props) { + const { t } = useT() + const [selected, setSelected] = useState('SKILL.md') + const [viewMode, setViewMode] = useState('preview') + // Real relative paths of the skill's directory (from the backend). + const [files, setFiles] = useState(['SKILL.md']) + // Fetched file bodies keyed by relative path; null ⇒ binary file. + const [bodies, setBodies] = useState>({}) + const fetchSeq = useRef(0) + + useEffect(() => { + if (!open || !skill) return + setSelected('SKILL.md') + setViewMode('preview') + // Seed SKILL.md from the already-loaded skill body for an instant first + // paint; the file list arrives async. + setBodies({ 'SKILL.md': skill.content ?? '' }) + setFiles(['SKILL.md']) + const seq = ++fetchSeq.current + api + .listSkillFiles(skill.id) + .then((rows) => { + if (seq !== fetchSeq.current) return + if (rows.length) setFiles(rows.map((r) => r.path)) + }) + .catch(() => {}) + }, [open, skill]) + + // Lazy-load the selected file's content (cached per path). + useEffect(() => { + if (!open || !skill) return + if (selected in bodies) return + const seq = fetchSeq.current + api + .getSkillFile(skill.id, selected) + .then((f) => { + if (seq !== fetchSeq.current) return + setBodies((prev) => ({ ...prev, [f.path]: f.content })) + }) + .catch(() => {}) + }, [open, skill, selected, bodies]) + + const treeData = useMemo(() => buildTree(files), [files]) + const body = skill ? bodies[selected] : undefined + + const language = languageFor(selected) + const isMarkdown = language === 'markdown' + const isBinary = body === null + + return ( + + {!skill ? null : ( +
+
- ) - }} - columns={[ - { - dataIndex: 'path', - render: (_: string, record: WorkspaceFile) => { - const displayName = record.path.slice(currentPath.length) - return ( - - {record.kind === 'folder' ? ( - - ) : ( - - )} - {record.kind === 'folder' ? ( - - ) : ( - - {displayName} - - )} + {/* File table (only this area scrolls; info bar + breadcrumb stay + fixed above it) */} +
+
+ ) + }} + columns={[ + { + dataIndex: 'path', + render: (_: string, record: WorkspaceFile) => { + const displayName = record.path.slice(currentPath.length) + return ( + + {record.kind === 'folder' ? ( + + ) : ( + + )} + {record.kind === 'folder' ? ( + + ) : ( + /* Same affordance as a folder row: a file opens its + preview instead of navigating into it. */ + + )} + + ) + } + }, + { + dataIndex: 'size', + width: 100, + render: (size: number) => ( + + {formatSize(size)} ) - } - }, - { - dataIndex: 'size', - width: 100, - render: (size: number) => ( - - {formatSize(size)} - - ) - }, - { - dataIndex: 'updated_at', - width: 140, - render: (date: string) => ( - - {getRelativeTime(date, t)} - - ) - }, - { - key: 'actions', - width: 120, - render: (_: unknown, record: WorkspaceFile) => ( - - - { - if (record.kind === 'folder') { - // Delete all files under this folder - const prefix = record.path + '/' - const children = (files ?? []).filter((f) => - f.path.startsWith(prefix) - ) - await Promise.all( - children.map((f) => - api - .deleteWorkspaceFile(project.id, f.path, { - silent: true - }) - .catch(() => {}) - ) - ) - } - await api - .deleteWorkspaceFile(project.id, record.path) - .catch(() => {}) - dispatchWorkspaceChanged() - }} - okText={t.workspace.delete} - cancelText={t.workspace.cancel} - > + }, + { + dataIndex: 'updated_at', + width: 140, + render: (date: string) => ( + + {getRelativeTime(date, t)} + + ) + }, + { + key: 'actions', + width: 120, + render: (_: unknown, record: WorkspaceFile) => ( + - - - ) - } - ]} - /> + { + if (record.kind === 'folder') { + // Delete all files under this folder + const prefix = record.path + '/' + const children = (files ?? []).filter((f) => + f.path.startsWith(prefix) + ) + await Promise.all( + children.map((f) => + api + .deleteWorkspaceFile(project.id, f.path, { + silent: true + }) + .catch(() => {}) + ) + ) + } + await api + .deleteWorkspaceFile(project.id, record.path) + .catch(() => {}) + dispatchWorkspaceChanged() + }} + okText={t.workspace.delete} + cancelText={t.workspace.cancel} + > + + + + ) + } + ]} + /> + ) : ( -
+
{/* Info bar */} -
+
@@ -784,7 +850,7 @@ function WorkspacePanel({ project }: { project: Project }) {
-
+
diff --git a/webui/frontend/app/components/project/ProjectWidgetRail.tsx b/webui/frontend/app/components/project/ProjectWidgetRail.tsx index d9eb246fb..e8e191de2 100644 --- a/webui/frontend/app/components/project/ProjectWidgetRail.tsx +++ b/webui/frontend/app/components/project/ProjectWidgetRail.tsx @@ -1,5 +1,6 @@ import { InstructionsCard } from '~/components/widgets/InstructionsCard' import { MemoryCard } from '~/components/widgets/MemoryCard' +import { MemoryDocCard } from '~/components/widgets/MemoryDocCard' import type { Project, Scope } from '~/lib/types' interface Props { @@ -11,7 +12,17 @@ export function ProjectWidgetRail({ project }: Props) { return (
- + {/* Two shapes of memory, chosen by the project's storage backend: + - file: memory IS one markdown file the agent reads — preview it and + edit the whole document in a drawer; + - vector: individually embedded memories, written by the agent's own + fact extraction — a read-only list whose only action is + removing one the agent got wrong. */} + {project.memory_backend === 'vector' ? ( + + ) : ( + + )}
) } diff --git a/webui/frontend/app/components/resources/McpCustomModal.tsx b/webui/frontend/app/components/resources/McpCustomModal.tsx index 9267fbf18..69bc2375c 100644 --- a/webui/frontend/app/components/resources/McpCustomModal.tsx +++ b/webui/frontend/app/components/resources/McpCustomModal.tsx @@ -1,10 +1,10 @@ -import { Form, Input, Modal, Radio, Segmented, message } from 'antd' +import { App, Form, Input, Modal, Radio, Segmented } from 'antd' import { useEffect, useState } from 'react' import { CodeEditor } from '~/components/common/CodeEditor' import { api } from '~/lib/api' import { useT } from '~/lib/i18n' import type { Mcp, McpTransport, Scope } from '~/lib/types' -import { fromMcpServers } from './mcpJson' +import { fromMcpServers, toMcpServers } from './mcpJson' interface Props { open: boolean @@ -41,6 +41,7 @@ export function McpCustomModal({ onSaved }: Props) { const { t } = useT() + const { message } = App.useApp() const isEdit = !!editingMcp const [tab, setTab] = useState('form') const [text, setText] = useState('') @@ -55,8 +56,11 @@ export function McpCustomModal({ useEffect(() => { if (open) { setTab('form') - setText('') if (editingMcp) { + // Both tabs are views of the SAME server, so the JSON one is seeded with + // the server's current config — an empty editor would look like there is + // nothing to edit, and saving it would wipe the config. + setText(JSON.stringify(toMcpServers([editingMcp]), null, 2)) form.setFieldsValue({ name: editingMcp.name, transport: editingMcp.transport, @@ -64,6 +68,7 @@ export function McpCustomModal({ description: editingMcp.description }) } else { + setText('') form.resetFields() } } @@ -96,10 +101,20 @@ export function McpCustomModal({ onClose() return } + // Editing targets ONE existing server: creating from here would leave the + // edited server untouched and silently add a duplicate beside it. + if (isEdit && parsed.length > 1) { + message.error(t.mcpImport.editSingleOnly) + return + } setSubmitting(true) try { - for (const m of parsed) { - await api.createMcp({ ...m, scope }) + if (isEdit) { + await api.updateMcp(editingMcp!.id, parsed[0]) + } else { + for (const m of parsed) { + await api.createMcp({ ...m, scope }) + } } onSaved() } finally { @@ -115,7 +130,9 @@ export function McpCustomModal({ onCancel={onClose} title={
- {t.mcpImport.customTitle} + + {isEdit ? t.mcpImport.editTitle : t.mcpImport.customTitle} + {scopeBadge && ( {scopeBadge} @@ -134,6 +151,8 @@ export function McpCustomModal({ value={tab} onChange={setTab} options={[ + // Neutral labels: these switch the VIEW (form vs raw JSON), and the + // modal title already says whether we are adding or editing. { value: 'form', label: t.mcpImport.formTab }, { value: 'json', label: t.mcpImport.jsonTab } ]} @@ -190,16 +209,14 @@ export function McpCustomModal({ ) : (
- {!text && ( -
-              {TEMPLATE}
-            
- )} + {/* The template hint is passed to monaco (not drawn as an overlay), so + it lands on the same baseline/indent as the caret. */}
)} diff --git a/webui/frontend/app/components/resources/McpJsonView.tsx b/webui/frontend/app/components/resources/McpJsonView.tsx index fc49124a1..cf6f5c83f 100644 --- a/webui/frontend/app/components/resources/McpJsonView.tsx +++ b/webui/frontend/app/components/resources/McpJsonView.tsx @@ -1,4 +1,4 @@ -import { Button, message } from 'antd' +import { App, Button } from 'antd' import { useEffect, useMemo, useState } from 'react' import { CodeEditor } from '~/components/common/CodeEditor' import { api } from '~/lib/api' @@ -20,6 +20,7 @@ export function McpJsonView({ onCancel }: McpJsonViewProps) { const { t } = useT() + const { message } = App.useApp() const original = useMemo( () => JSON.stringify(toMcpServers(items), null, 2), [items] @@ -43,10 +44,14 @@ export function McpJsonView({ } setSaving(true) try { - await Promise.all(items.map((m) => api.deleteMcp(m.id))) - for (const m of parsed) { - await api.createMcp({ ...m, scope }) - } + // ONE atomic call. Deleting every server and re-creating them from the + // document (what this did before) meant a rename left the old server + // behind whenever its delete was lost to a concurrent one, and a single + // rejected entry wiped the whole scope, since the deletes had landed. + await api.replaceMcps( + scope, + parsed.map((m) => ({ ...m, scope })) + ) onSaved() } finally { setSaving(false) diff --git a/webui/frontend/app/components/resources/McpsPanel.tsx b/webui/frontend/app/components/resources/McpsPanel.tsx index 83da2d9b8..a508e0c82 100644 --- a/webui/frontend/app/components/resources/McpsPanel.tsx +++ b/webui/frontend/app/components/resources/McpsPanel.tsx @@ -66,7 +66,7 @@ export function McpsPanel({ ) : items === null ? ( ) : items.length === 0 ? ( - + ) : ( <>
diff --git a/webui/frontend/app/components/resources/SkillDetailDrawer.tsx b/webui/frontend/app/components/resources/SkillDetailDrawer.tsx index 3ff4a1655..a792939b2 100644 --- a/webui/frontend/app/components/resources/SkillDetailDrawer.tsx +++ b/webui/frontend/app/components/resources/SkillDetailDrawer.tsx @@ -9,6 +9,7 @@ import { useT } from '~/lib/i18n' import type { Skill } from '~/lib/types' import ViewIcon from '~/assets/icons/view.svg?react' import TerminalIcon from '~/assets/icons/terminal.svg?react' +import { languageFor } from '~/lib/editorLanguage' interface Props { open: boolean @@ -21,30 +22,6 @@ interface Props { type ViewMode = 'preview' | 'code' -const LANGUAGE_BY_EXT: Record = { - md: 'markdown', - markdown: 'markdown', - py: 'python', - ts: 'typescript', - tsx: 'typescript', - js: 'javascript', - jsx: 'javascript', - json: 'json', - sh: 'shell', - bash: 'shell', - yaml: 'yaml', - yml: 'yaml', - svg: 'xml', - xml: 'xml', - html: 'html', - css: 'css', - txt: 'plaintext' -} - -function languageFor(path: string): string { - const ext = path.split('.').pop()?.toLowerCase() ?? '' - return LANGUAGE_BY_EXT[ext] ?? 'plaintext' -} /** Build a FolderTree data set from the backend's flat relative-path list. * Keys follow the FolderTree convention (`dir:` / `file:`) so the diff --git a/webui/frontend/app/components/resources/SkillsFromLocalModal.tsx b/webui/frontend/app/components/resources/SkillsFromLocalModal.tsx index 5d1276b77..dceb684af 100644 --- a/webui/frontend/app/components/resources/SkillsFromLocalModal.tsx +++ b/webui/frontend/app/components/resources/SkillsFromLocalModal.tsx @@ -1,17 +1,35 @@ -import { Button, Input, Modal, Upload, Typography } from 'antd' +import { App, Button, Input, Modal, Segmented } from 'antd' import { useEffect, useRef, useState } from 'react' -import { MsaButton } from '~/components/common/MsaButton' import { api } from '~/lib/api' +import { collectDroppedEntries } from '~/lib/dropFiles' +import type { DroppedFile } from '~/lib/dropFiles' import { useT } from '~/lib/i18n' import type { Scope, Skill } from '~/lib/types' import UploadIcon from '~/assets/icons/upload.svg?react' import CloseIcon from '~/assets/icons/close.svg?react' import FolderIcon from '~/assets/icons/folder.svg?react' -import { FileTypeIcon } from '~/components/common/FileCard' -type LocalPick = - | { kind: 'file'; uid: string; name: string; size: number; files: File[] } - | { kind: 'dir'; uid: string; name: string; fileCount: number; files: File[] } +// A skill IS a directory: its folder name becomes the skill name and its +// SKILL.md carries the metadata. So only folders can be imported — a lone file +// has nothing to name the skill after (and the backend bundle expects the tree). +// +// Files keep the path they had INSIDE the pick: the backend locates SKILL.md and +// re-roots the bundle on its directory, so a flattened list would collapse +// `scripts/run.py` to `run.py` and destroy the skill's layout. +type LocalPick = { + uid: string + name: string + files: DroppedFile[] +} + +// The two sources are mutually exclusive and behave differently server-side: +// uploading COPIES the files into the scope's skills tree (kind 'bundle'), +// while a path REFERENCES a directory the backend can already read (kind +// 'source', registered via add_source). Making the choice an explicit mode is +// what keeps `submit` from silently dropping one of them — the old code took +// the path whenever it was non-empty, discarding any picked files without a +// word. +type ImportMode = 'upload' | 'path' interface Props { open: boolean @@ -22,12 +40,6 @@ interface Props { const BUNDLE_FORMAT = 'webui.skill.bundle.v1' -function formatSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` -} - function sourceName(path: string): string { const normalized = path.trim().replace(/\/+$/, '') return normalized.split('/').filter(Boolean).pop() || normalized || 'skill' @@ -38,10 +50,10 @@ function relativePath(file: File): string { return rel || file.name } -async function bundleContent(files: File[]): Promise { +async function bundleContent(files: DroppedFile[]): Promise { const payloadFiles = await Promise.all( - files.map(async (file) => ({ - path: relativePath(file), + files.map(async ({ file, path }) => ({ + path, content: await file.text() })) ) @@ -55,17 +67,26 @@ export function SkillsFromLocalModal({ onImported }: Props) { const { t } = useT() + const { message } = App.useApp() + const [mode, setMode] = useState('upload') const [picks, setPicks] = useState([]) const [localPath, setLocalPath] = useState('') const [submitting, setSubmitting] = useState(false) + const [dragging, setDragging] = useState(false) const folderInputRef = useRef(null) useEffect(() => { if (!open) return + setMode('upload') setPicks([]) setLocalPath('') + setDragging(false) }, [open]) + // Only the active mode's input counts, so "Import" can state up front whether + // there is anything to import instead of quietly closing on an empty submit. + const ready = mode === 'upload' ? picks.length > 0 : localPath.trim() !== '' + // Callback ref: the folder input lives inside a `destroyOnHidden` Modal, so it // is created *after* mount (and re-created each open). A one-shot useEffect([]) // runs while the input doesn't exist yet and never sets these attributes — so @@ -80,30 +101,37 @@ export function SkillsFromLocalModal({ } const submit = async () => { - const path = localPath.trim() - if (!path && picks.length === 0) { - onClose() - return - } + if (!ready) return setSubmitting(true) try { - const skill = path - ? await api.createSkill({ - name: sourceName(path), + if (mode === 'path') { + onImported( + await api.createSkill({ + name: sourceName(localPath.trim()), kind: 'source', - content: path, - enabled: true, - scope - }) - : await api.createSkill({ - name: picks[0]?.name ?? 'skill', - kind: 'bundle', - content: await bundleContent(picks.flatMap((pick) => pick.files)), + content: localPath.trim(), enabled: true, scope }) - onImported(skill) + ) + return + } + // ONE SKILL PER FOLDER. Merging the picks into a single bundle (what this + // did before) produced one skill named after the first folder, carrying + // every folder's files — the backend picks the first SKILL.md it finds and + // re-roots everything on that directory, so the rest arrived mangled. + let last: Skill | null = null + for (const pick of picks) { + last = await api.createSkill({ + name: pick.name, + kind: 'bundle', + content: await bundleContent(pick.files), + enabled: true, + scope + }) + } + if (last) onImported(last) } catch { // API errors surface via the global toast (see root ApiErrorBridge). } finally { @@ -111,15 +139,14 @@ export function SkillsFromLocalModal({ } } - const addFile = (file: File) => { + const addFolder = (name: string, files: DroppedFile[]) => { + if (files.length === 0) return setPicks((prev) => [ ...prev, { - kind: 'file', - uid: `${file.name}-${file.lastModified}-${file.size}`, - name: file.name, - size: file.size, - files: [file] + uid: `${name}-${Date.now()}-${files.length}`, + name, + files } ]) } @@ -128,20 +155,35 @@ export function SkillsFromLocalModal({ const files = Array.from(e.target.files ?? []) if (files.length === 0) return const rel = relativePath(files[0]) - const dirName = rel.includes('/') ? rel.split('/')[0] : 'folder' - setPicks((prev) => [ - ...prev, - { - kind: 'dir', - uid: `${dirName}-${Date.now()}`, - name: dirName, - fileCount: files.length, - files - } - ]) + // The picker fills webkitRelativePath (`my-skill/SKILL.md`), which is the + // same shape the entry-tree walk produces for a drop. + addFolder( + rel.includes('/') ? rel.split('/')[0] : 'folder', + files.map((file) => ({ file, path: relativePath(file) })) + ) e.target.value = '' } + // Folder drops only. `DataTransfer.files` cannot tell a directory from a file + // (a dropped folder arrives as one unreadable 96 B "file"), which is why a + // folder used to be listed as a file here — collectDroppedEntries walks the + // real entry tree instead, so we know which is which. + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDragging(false) + const entries = await collectDroppedEntries(e.dataTransfer) + const folders = entries.filter((entry) => entry.isDirectory) + if (folders.length === 0) { + if (entries.length > 0) message.warning(t.skillImport.localFolderOnly) + return + } + for (const folder of folders) addFolder(folder.name, folder.files) + // Loose files alongside a folder are ignored rather than silently bundled. + if (folders.length !== entries.length) + message.warning(t.skillImport.localFolderOnly) + } + const removePick = (uid: string) => setPicks((prev) => prev.filter((pick) => pick.uid !== uid)) @@ -153,105 +195,106 @@ export function SkillsFromLocalModal({ okText={t.skillImport.localUpload} cancelText={t.resources.cancel} onOk={submit} - okButtonProps={{ loading: submitting }} + okButtonProps={{ loading: submitting, disabled: !ready }} destroyOnHidden width={560} > - { - addFile(file) - return false - }} - className="bg-msa-fill-1 border-msa-line-1" - > -

- -

-

- {t.skillImport.localDropHint} -

-
- { - addFile(file) - return false + + value={mode} + onChange={setMode} + options={[ + { value: 'upload', label: t.skillImport.localModeUpload }, + { value: 'path', label: t.skillImport.localModePath } + ]} + className="mb-2" + /> +

+ {mode === 'upload' + ? t.skillImport.localUploadHint + : t.skillImport.localPathHint} +

+ + {mode === 'upload' ? ( + <> + {/* Plain drop zone rather than antd's Upload.Dragger: the Dragger funnels + every drop through `beforeUpload(file)`, which can neither tell a + folder from a file nor reject one. + The whole area is the control — with only one possible action (pick a + folder) a separate button inside it would just be a second way to do + the same thing. */} +
folderInputRef.current?.click()} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + folderInputRef.current?.click() + } }} - > - - {t.skillImport.localPickFile} - - - { - e.stopPropagation() - folderInputRef.current?.click() + onDragOver={(e) => { + if (!e.dataTransfer.types.includes('Files')) return + e.preventDefault() + e.dataTransfer.dropEffect = 'copy' + if (!dragging) setDragging(true) }} + onDragLeave={() => setDragging(false)} + onDrop={handleDrop} > - {t.skillImport.localPickFolder} - -
- + +

+ {t.skillImport.localDropHint} +

+
- - - {picks.length > 0 && ( -
    - {picks.map((pick) => ( -
  • - {/* Project's own file badge / folder glyph (same as the - workspace tree) instead of generic doc icons. */} - {pick.kind === 'file' ? ( - - ) : ( - - )} - - {pick.name} - - - {pick.kind === 'file' - ? formatSize(pick.size) - : `${pick.fileCount} ${t.skillImport.localFolderFiles}`} - -
  • - ))} -
- )} + -
+ {picks.length > 0 && ( +
    + {picks.map((pick) => ( +
  • + + + {pick.name} + + + {`${pick.files.length} ${t.skillImport.localFolderFiles}`} + +
  • + ))} +
+ )} + + ) : ( setLocalPath(e.target.value)} placeholder={t.skillImport.localPathPlaceholder} /> - - {t.skillImport.localPathHint} - -
+ )} -
+

{t.skillImport.localFileReqTitle}

diff --git a/webui/frontend/app/components/resources/SkillsPanel.tsx b/webui/frontend/app/components/resources/SkillsPanel.tsx index 51145a401..d8ffb543e 100644 --- a/webui/frontend/app/components/resources/SkillsPanel.tsx +++ b/webui/frontend/app/components/resources/SkillsPanel.tsx @@ -52,7 +52,7 @@ export function SkillsPanel({ {items === null ? ( ) : items.length === 0 ? ( - + ) : ( <>
diff --git a/webui/frontend/app/components/session/SessionRightRail.tsx b/webui/frontend/app/components/session/SessionRightRail.tsx index 01ece63a5..1c28aab4c 100644 --- a/webui/frontend/app/components/session/SessionRightRail.tsx +++ b/webui/frontend/app/components/session/SessionRightRail.tsx @@ -12,11 +12,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { CodeEditor } from '~/components/common/CodeEditor' import { EmptyState } from '~/components/common/EmptyState' import { FolderTree } from '~/components/common/FolderTree' +import { languageFor } from '~/lib/editorLanguage' import { DeferredSkeleton } from '~/components/common/DeferredSkeleton' import type { FolderTreeActions } from '~/components/common/FolderTree' import { IconButton } from '~/components/common/IconButton' import { api } from '~/lib/api' import { dispatchWorkspaceChanged, useOnWorkspaceChanged } from '~/lib/events' +import { collectDroppedFiles } from '~/lib/dropFiles' import { downloadWorkspaceAll, downloadWorkspaceFile } from '~/lib/download' import { useT } from '~/lib/i18n' import type { Project, WorkspaceFile } from '~/lib/types' @@ -112,27 +114,6 @@ function toTreeData(node: DirNode): TreeDataNode[] { return [...dirs, ...files] } -const LANGUAGE_BY_EXT: Record = { - md: 'markdown', - py: 'python', - ts: 'typescript', - tsx: 'typescript', - js: 'javascript', - jsx: 'javascript', - json: 'json', - sh: 'shell', - yaml: 'yaml', - yml: 'yaml', - svg: 'xml', - html: 'html', - css: 'css', - txt: 'plaintext' -} - -function languageFor(path: string): string { - const ext = path.split('.').pop()?.toLowerCase() ?? '' - return LANGUAGE_BY_EXT[ext] ?? 'plaintext' -} type PreviewKind = 'text' | 'image' | 'video' | 'audio' | 'unsupported' @@ -517,14 +498,29 @@ export function SessionRightRail({ } const uploadTo = async (dir: string, fileList: FileList) => { - const uploads = Array.from(fileList).map((file) => + await uploadEntries( + dir, + Array.from(fileList).map((file) => ({ + file, + path: file.webkitRelativePath || file.name + })) + ) + } + + /** Upload files that already know their relative path — what a folder pick + * (webkitRelativePath) or a folder DROP (walked entry tree) both produce, so a + * dropped directory lands as its real contents instead of one unreadable + * directory "file". */ + const uploadEntries = async ( + dir: string, + entries: { file: File; path: string }[] + ) => { + if (entries.length === 0) return + const uploads = entries.map(({ file, path }) => api - .uploadWorkspaceFile( - project.id, - file, - joinPath(dir, file.webkitRelativePath || file.name), - { silent: [409] } - ) + .uploadWorkspaceFile(project.id, file, joinPath(dir, path), { + silent: [409] + }) .catch(() => {}) ) await Promise.all(uploads) @@ -602,7 +598,7 @@ export function SessionRightRail({ onCopyPath: copyPath, onDownload: (path) => downloadWorkspaceFile(project.id, path), onMove: moveEntry, - onUploadTo: uploadTo, + onUploadTo: uploadEntries, onDeleteMany: deleteMany, onDownloadMany: downloadMany, onCopyPaths: copyPaths, @@ -746,11 +742,13 @@ export function SessionRightRail({ // Folder nodes handle (and stop) their own drops. if (e.dataTransfer.types.includes('Files')) e.preventDefault() }} - onDrop={(e) => { + onDrop={async (e) => { if (!e.dataTransfer.types.includes('Files')) return e.preventDefault() - if (e.dataTransfer.files.length > 0) - uploadTo('', e.dataTransfer.files) + // Walk the entry tree: a dropped FOLDER is not in + // `dataTransfer.files` (it appears there as an unreadable + // directory entry), so it used to upload as a 96 B junk file. + uploadEntries('', await collectDroppedFiles(e.dataTransfer)) }} > {files === null ? ( diff --git a/webui/frontend/app/components/widgets/MemoryCard.tsx b/webui/frontend/app/components/widgets/MemoryCard.tsx index e2f33a201..ca7368397 100644 --- a/webui/frontend/app/components/widgets/MemoryCard.tsx +++ b/webui/frontend/app/components/widgets/MemoryCard.tsx @@ -1,97 +1,96 @@ -import { Button, Input, Popconfirm, Tooltip } from 'antd' -import { useEffect, useRef, useState } from 'react' +import { App, Button, Popconfirm, Tooltip, Typography } from 'antd' +import { useCallback, useEffect, useState } from 'react' import { IconButton } from '~/components/common/IconButton' import { EmptyState } from '~/components/common/EmptyState' import { api } from '~/lib/api' import { useT } from '~/lib/i18n' -import type { MemoryItem, Project } from '~/lib/types' +import type { MemoryItem, MemoryStatus, Project } from '~/lib/types' import { WidgetCard } from './WidgetCard' import { DeferredSkeleton } from '~/components/common/DeferredSkeleton' import MemoryIcon from '~/assets/icons/memory.svg?react' -import EditIcon from '~/assets/icons/edit.svg?react' import DeleteIcon from '~/assets/icons/delete.svg?react' -import AddIcon from '~/assets/icons/add.svg?react' interface Props { project: Project } +/** "provider/model" identity chip text for the resolved embedder. */ +function embedderLabel(status: MemoryStatus | null): string | null { + const e = status?.embedder + if (!e?.model) return null + const model = e.model.split('/').pop() || e.model + return e.mode === 'local' ? `local · ${model}` : `${e.provider} · ${model}` +} + export function MemoryCard({ project }: Props) { const { t } = useT() + const { message } = App.useApp() const [items, setItems] = useState([]) const [loaded, setLoaded] = useState(false) - const [editingId, setEditingId] = useState(null) - const [editingContent, setEditingContent] = useState('') - const [isCreating, setIsCreating] = useState(false) - const [newContent, setNewContent] = useState('') - const newInputRef = useRef(null) + const [status, setStatus] = useState(null) + const [rebuilding, setRebuilding] = useState(false) + // Why the list could not be read. Distinguishing this from "no memories yet" + // matters: an unusable vector backend (no embedder, identity mismatch) + // must read as a problem with a remedy, not as an empty store forever. + const [error, setError] = useState('') - const memoryOn = project.memory_enabled && !project.is_default + const memoryOn = project.memory_enabled - const refresh = () => { + const refresh = useCallback(() => { if (!memoryOn) { setItems([]) return } api - .listMemoryItems(project.id) - .then(setItems) - .catch(() => setItems([])) + // silent: failures are rendered in the card, so a global toast on every + // mount would just be a duplicate. + .listMemoryItems(project.id, { silent: true }) + .then((rows) => { + setItems(rows) + setError('') + }) + .catch((e: unknown) => { + setItems([]) + setError( + (e instanceof Error && e.message) || t.widgets.memoryLoadFailed + ) + }) .finally(() => setLoaded(true)) - } - useEffect(refresh, [project.id, memoryOn]) - - const startEdit = (item: MemoryItem) => { - setEditingId(item.id) - setEditingContent(item.content) - } + // Health surface: embedder identity, config errors, last ingest outcome. + api + .getMemoryStatus(project.id, { silent: true }) + .then(setStatus) + .catch(() => setStatus(null)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [project.id, memoryOn]) + useEffect(refresh, [refresh]) - const cancelEdit = () => { - setEditingId(null) - setEditingContent('') - } - - const saveEdit = async () => { - if (!editingId) { - cancelEdit() - return - } - const trimmed = editingContent.trim() - if (!trimmed) { - // Clearing content = delete the memory item. - await api.deleteMemoryItem(project.id, editingId) - } else { - await api.updateMemoryItem(project.id, editingId, trimmed) - } - setEditingId(null) - refresh() - } + // A background ingest may be in flight right after a turn — poll briefly + // while the server reports scheduled/running so the new rows appear without + // a manual refresh. + useEffect(() => { + const state = status?.ingest?.state + if (state !== 'scheduled' && state !== 'running') return + const timer = setTimeout(refresh, 2000) + return () => clearTimeout(timer) + }, [status, refresh]) const deleteItem = async (id: string) => { await api.deleteMemoryItem(project.id, id) refresh() } - const startCreate = () => { - setIsCreating(true) - setNewContent('') - setTimeout(() => newInputRef.current?.focus(), 50) - } - - const cancelCreate = () => { - setIsCreating(false) - setNewContent('') - } - - const saveCreate = async () => { - if (!newContent.trim()) { - cancelCreate() - return + const rebuild = async () => { + setRebuilding(true) + try { + await api.rebuildMemory(project.id) + message.success(t.widgets.memoryRebuilt) + refresh() + } catch { + // surfaced by the global toast + } finally { + setRebuilding(false) } - await api.createMemoryItem(project.id, newContent.trim()) - setIsCreating(false) - setNewContent('') - refresh() } if (!memoryOn) { @@ -99,6 +98,7 @@ export function MemoryCard({ project }: Props) { } + badge={t.newProject.backendVector} >
{t.widgets.memoryDisabled} @@ -107,112 +107,130 @@ export function MemoryCard({ project }: Props) { ) } + const chip = embedderLabel(status) + const ingest = status?.ingest + const mismatch = status?.error?.code === 'embedder_mismatch' + return ( } count={items.length} + // This card is the VECTOR shape of memory; the tag says so, since the file + // backend gets a completely different (document) UI. + badge={t.newProject.backendVector} + // Embedder identity in the top-right, next to the title — it's metadata + // about the whole store, so it reads better as a header chip than as a + // footer that scrolls with (and gets mistaken for) the memory rows. + extra={ + chip ? ( + + {chip} + + ) : undefined + } className="flex-initial min-h-0 flex flex-col overflow-hidden" bodyClassName="flex-1 overflow-y-auto" > {!loaded ? ( - ) : items.length === 0 && !isCreating ? ( + ) : error || status?.error ? ( +
+

+ {t.widgets.memoryLoadFailed} +

+ {/* The backend's own reason, verbatim — it names the actual cause + (identity mismatch, missing local model, …), the only thing that + tells the user what to go fix. */} +

+ {status?.error?.message || error} +

+ {mismatch && ( + /* The mismatch's remedy: start over with the current embedder. + The old store is moved aside server-side, never deleted. */ + + + + )} +
+ ) : items.length === 0 ? ( ) : ( + /* Read-only list: vector memories are produced by the agent's own fact + extraction during conversation, so there is no hand-authoring here — + the only action is removing one the agent got wrong. (The file backend + is the editable one; see MemoryDocCard.) */
- {items.map((item) => - editingId === item.id ? ( -
- setEditingContent(e.target.value)} - onBlur={cancelEdit} - onPressEnter={(e) => { - if (!e.shiftKey) { - e.preventDefault() - saveEdit() - } - }} - autoSize={{ minRows: 2, maxRows: 6 }} - autoFocus - classNames={{ textarea: 'resize-none text-sm' }} - /> -
- ) : ( -
( +
+ {/* Clamped to two lines, so the full fact needs a tooltip to stay + readable. antd's `ellipsis` measures the node and only attaches + one when the text is ACTUALLY cut — a plain `title` would pop up + on short memories too. */} + + {item.content} + + deleteItem(item.id)} > -

- {item.content} -

- + } + icon={} size="xs" variant="ghost" - className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100" - onClick={() => startEdit(item)} + className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100 hover:!text-msa-text-danger" /> - deleteItem(item.id)} - > - - } - size="xs" - variant="ghost" - className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100 hover:!text-msa-text-danger" - /> - - -
- ) - )} - - {isCreating && ( -
- setNewContent(e.target.value)} - onBlur={cancelCreate} - onPressEnter={(e) => { - if (!e.shiftKey) { - e.preventDefault() - saveCreate() - } - }} - autoSize={{ minRows: 2, maxRows: 6 }} - autoFocus - placeholder={t.widgets.addMemoryPlaceholder} - classNames={{ textarea: 'resize-none text-sm' }} - /> +
- )} + ))}
)} - -
- -
+ {/* Footer: transient ingest states only (updating / failed). The embedder + identity moved to the header chip; the count is already in the title. */} + {loaded && + (ingest?.state === 'scheduled' || + ingest?.state === 'running' || + ingest?.state === 'error') && ( +
+ {ingest.state === 'error' ? ( + + + {t.widgets.memoryIngestFailed} + + + ) : ( + + {t.widgets.memoryIngesting} + + )} +
+ )} ) } diff --git a/webui/frontend/app/components/widgets/MemoryDocCard.tsx b/webui/frontend/app/components/widgets/MemoryDocCard.tsx new file mode 100644 index 000000000..869382e20 --- /dev/null +++ b/webui/frontend/app/components/widgets/MemoryDocCard.tsx @@ -0,0 +1,193 @@ +import { App, Button, Drawer, Segmented, Tooltip } from 'antd' +import { useCallback, useEffect, useState } from 'react' +import { CodeEditor } from '~/components/common/CodeEditor' +import { DeferredSkeleton } from '~/components/common/DeferredSkeleton' +import { EmptyState } from '~/components/common/EmptyState' +import { Markdown } from '~/components/common/Markdown' +import { api } from '~/lib/api' +import { useT } from '~/lib/i18n' +import type { Project } from '~/lib/types' +import { WidgetCard } from './WidgetCard' +import MemoryIcon from '~/assets/icons/memory.svg?react' +import TerminalIcon from '~/assets/icons/terminal.svg?react' +import ViewIcon from '~/assets/icons/view.svg?react' + +/** Which face of the draft the drawer shows: the editor or its rendered form. */ +type DraftView = 'edit' | 'preview' + +/** + * Memory card for `memory_backend === 'file'`. + * + * That backend keeps memory as ONE markdown file (`MEMORY.md`) which the agent + * reads every turn — not a list of rows. So the card previews the rendered + * document and edits it wholesale in a drawer, instead of the read-only + * per-row list the vector backend gets (see MemoryCard). + */ +export function MemoryDocCard({ project }: { project: Project }) { + const { t } = useT() + const { message } = App.useApp() + const [content, setContent] = useState('') + // null = not loaded yet (skeleton); '' = loaded and genuinely empty. + const [loaded, setLoaded] = useState(false) + const [open, setOpen] = useState(false) + const [draft, setDraft] = useState('') + const [view, setView] = useState('edit') + const [saving, setSaving] = useState(false) + // Reset/Save stay disabled until the draft diverges from what is stored. + const dirty = draft !== content + + const refresh = useCallback(() => { + api + .getMemoryDoc(project.id, { silent: true }) + .then((d) => setContent(d.content ?? '')) + .catch(() => setContent('')) + .finally(() => setLoaded(true)) + }, [project.id]) + + useEffect(() => { + setLoaded(false) + refresh() + }, [refresh]) + + const openEditor = () => { + setDraft(content) + setView('edit') + setOpen(true) + } + + const save = async () => { + setSaving(true) + try { + const saved = await api.putMemoryDoc(project.id, draft) + setContent(saved.content ?? '') + setOpen(false) + message.success(t.widgets.memorySaved) + } finally { + setSaving(false) + } + } + + if (!project.memory_enabled) { + return ( + } + badge={t.newProject.backendFile} + > +
+ {t.widgets.memoryDisabled} +
+
+ ) + } + + return ( + <> + } + // Marks this as the FILE shape of memory (one markdown document). + badge={t.newProject.backendFile} + onEdit={loaded ? openEditor : undefined} + className="flex-initial min-h-0 flex flex-col overflow-hidden" + bodyClassName="flex-1 overflow-y-auto" + > + {!loaded ? ( + + ) : content.trim() ? ( + + ) : ( + + )} + + + setOpen(false)} + title={t.widgets.memoryDocDrawerTitle} + placement="right" + size="min(720px, 92vw)" + footer={ + /* Cancel / Reset / Save, matching the MCP JSON editor: cancel always + available, the other two only once the draft actually differs from + what is stored. */ +
+ + + +
+ } + > +
+
+ + {t.widgets.memoryDocHint} + + {/* Edit / preview toggle — same control and icons the skill viewer + uses, so switching views feels identical across the app. The + draft is what gets previewed, so unsaved edits render live. */} + + size="small" + value={view} + onChange={setView} + options={[ + { + value: 'edit', + icon: ( + + + + ) + }, + { + value: 'preview', + icon: ( + + + + ) + } + ]} + /> +
+
+ {view === 'edit' ? ( + + ) : ( +
+ {draft.trim() ? ( + + ) : ( + + )} +
+ )} +
+
+
+ + ) +} diff --git a/webui/frontend/app/components/widgets/WidgetCard.tsx b/webui/frontend/app/components/widgets/WidgetCard.tsx index abf8cdfcb..aa310d44c 100644 --- a/webui/frontend/app/components/widgets/WidgetCard.tsx +++ b/webui/frontend/app/components/widgets/WidgetCard.tsx @@ -7,6 +7,9 @@ interface Props { title: string icon?: React.ReactNode count?: number + /** Small tag after the title/count — e.g. which memory storage backend the + * project uses, so the two very different memory UIs are self-explaining. */ + badge?: string onEdit?: () => void extra?: React.ReactNode className?: string @@ -18,6 +21,7 @@ export function WidgetCard({ title, icon, count, + badge, onEdit, extra, className, @@ -29,6 +33,8 @@ export function WidgetCard({ )} + {badge && ( + /* fill-5 rather than fill-purple: the latter is #edefff / #141414, i.e. + within ~1.1 contrast of the card surface in BOTH themes, so the + chip shape was invisible and only its text showed. */ + + {badge} + + )}
} extra={ -
+
{onEdit && ( @@ -65,7 +93,14 @@ export default function AppLayout() { closable={false} styles={{ body: { padding: 0 } }} > - setSidebarDrawer(false)} /> + {/* The sidebar's own logo/collapse toggle must CLOSE the drawer here: + there is no compact mode on small screens, and leaving + `onCollapse` unset made that button inert (it renders and reacts to + hover, but clicking did nothing). */} + setSidebarDrawer(false)} + onNavigate={() => setSidebarDrawer(false)} + />
diff --git a/webui/frontend/app/layouts/settings.tsx b/webui/frontend/app/layouts/settings.tsx index 77b73763d..3e3c4d6d5 100644 --- a/webui/frontend/app/layouts/settings.tsx +++ b/webui/frontend/app/layouts/settings.tsx @@ -1,3 +1,4 @@ +import { Tooltip } from 'antd' import { NavLink, Outlet, useNavigate } from 'react-router' import IconModelSettings from '~/assets/icons/model-settings.svg?react' import IconMcpSkill from '~/assets/icons/mcp-skill-manage.svg?react' @@ -10,6 +11,7 @@ import { useT } from '~/lib/i18n' import { metaDict, pageTitle } from '~/lib/pageTitle' import type { Route } from './+types/settings' import { getLastAppRoute } from '~/lib/lastAppRoute' +import { useMatchMedia } from '~/lib/useMatchMedia' /** Fallback for settings routes without their own `meta` (the index route * redirects to Models, so it only flashes). Concrete pages override this. */ @@ -21,6 +23,12 @@ export function meta({ matches }: Route.MetaArgs) { export default function SettingsLayout() { const { t } = useT() const navigate = useNavigate() + // Below `md` this rail collapses to icons only (CSS-driven, see the + // `hidden md:inline` labels), leaving nothing to say what each icon is — so + // the label moves into a tooltip. Above `md` the label is right there and a + // tooltip would only repeat it. Tooltips add no DOM until hovered, so keying + // this off a media-query hook costs no layout and cannot flash. + const compact = !useMatchMedia('(min-width: 768px)') const items: { to: string; label: string; icon: typeof IconModelSettings }[] = [ @@ -67,33 +75,40 @@ export default function SettingsLayout() { {/* Nav menu */} {/* Back button */} - + + + {/* Right content */} diff --git a/webui/frontend/app/lib/agentProvider.ts b/webui/frontend/app/lib/agentProvider.ts index 6fe4ac38c..43f38a525 100644 --- a/webui/frontend/app/lib/agentProvider.ts +++ b/webui/frontend/app/lib/agentProvider.ts @@ -69,6 +69,8 @@ export type StepKind = | "terminal" | "tool_call" | "skill_load" + | "skill_list" + | "skill_manage" | "file_read" | "file_write" | "file_edit" @@ -411,6 +413,20 @@ function appendTaskSnapshot( * auth card already tells the story). */ function appendStepPart(parts: AgentPart[], meta: Record): void { const isRunning = meta.status === "running"; + // Paths this step just wrote, handed to the workspace listeners so a freshly + // written file is merged into the live file set immediately. Without them the + // card renders against the PREVIOUS set, which already covers the directory — + // and "covered directory, path absent" reads as deleted, so the card flashed + // "this file was deleted" until the refetch landed. + const writtenPaths = (): string[] => { + const multi = Array.isArray(meta.paths) + ? (meta.paths as unknown[]).map(String).filter(Boolean) + : []; + if (multi.length > 0) return multi; + const single = String(meta.path ?? ""); + return single ? [single] : []; + }; + const notifyWorkspace = () => dispatchWorkspaceChanged(writtenPaths()); // Live-card merge by call_id: a tool's "running" card (emitted on // tool_call_started) is replaced IN PLACE by its completed / interrupted step // (same call_id) — so a slow tool shows an immediate "executing" card that @@ -421,13 +437,11 @@ function appendStepPart(parts: AgentPart[], meta: Record): void for (let i = parts.length - 1; i >= 0; i--) { const p = parts[i]; if (p.kind === "step" && String(p.step.meta.call_id ?? "") === callId) { - // A rejected authorization keeps its card; an errored result must not - // overwrite the "rejected" story. - if ( - p.step.meta.kind === "authorization" && - p.step.meta.state === "rejected" && - meta.status === "error" - ) { + // A rejected ask keeps its card; an errored result must not overwrite + // the "rejected" story. Keyed on `state`, not on the card kind: a shell + // ask renders as its own terminal card (backend _AUTH_INLINE_KINDS), so + // the rejection can live on any step kind. + if (p.step.meta.state === "rejected" && meta.status === "error") { return; } parts[i] = { kind: "step", step: { kind: meta.kind as StepKind, meta } }; @@ -435,28 +449,30 @@ function appendStepPart(parts: AgentPart[], meta: Record): void !isRunning && (meta.kind === "file_write" || meta.kind === "file_edit") ) - dispatchWorkspaceChanged(); + notifyWorkspace(); return; } } } // Fallback for buffers/history without call_id: continue an authorization card - // by tool_name when the tool result arrives. + // by tool_name when the tool result arrives. `tool_name` is stamped only on + // ask cards, so it identifies one whatever kind it renders as (a shell ask is + // a terminal card). if (meta.kind !== "authorization") { const name = String(meta.tool ?? meta.name ?? ""); for (let i = parts.length - 1; i >= 0; i--) { const p = parts[i]; if ( p.kind === "step" && - p.step.kind === "authorization" && - String(p.step.meta.tool_name ?? "") === name + String(p.step.meta.tool_name ?? "") === name && + name !== "" ) { if (p.step.meta.state === "rejected" && meta.status === "error") { return; // rejection already shown by the auth card } parts[i] = { kind: "step", step: { kind: meta.kind as StepKind, meta } }; if (meta.kind === "file_write" || meta.kind === "file_edit") - dispatchWorkspaceChanged(); + notifyWorkspace(); return; } } @@ -465,7 +481,7 @@ function appendStepPart(parts: AgentPart[], meta: Record): void // Notify workspace when a file is actually WRITTEN (not while still running) // so the file list refreshes mid-turn (not waiting for the turn to finish). if (!isRunning && (meta.kind === "file_write" || meta.kind === "file_edit")) { - dispatchWorkspaceChanged(); + notifyWorkspace(); } } diff --git a/webui/frontend/app/lib/api.ts b/webui/frontend/app/lib/api.ts index 8879467e7..509a4ad96 100644 --- a/webui/frontend/app/lib/api.ts +++ b/webui/frontend/app/lib/api.ts @@ -5,7 +5,9 @@ import type { Mcp, McpHealth, MemoryBackend, + MemoryDoc, MemoryItem, + MemoryStatus, Model, Profile, Project, @@ -211,6 +213,9 @@ export const api = { description: string local_path: string memory_enabled: boolean + // Accepted only while the project has never had memory enabled; the + // server answers 400 for a change once it is locked. + memory_backend: MemoryBackend mcp_auto_attach: boolean skill_auto_attach: boolean permission_mode: 'restricted' | 'auto' @@ -255,6 +260,15 @@ export const api = { listMcps: (scope?: Scope) => json(`/api/mcps${q({ scope })}`), createMcp: (body: Omit) => json('/api/mcps', { method: 'POST', body: JSON.stringify(body) }), + /** Replace a scope's servers with exactly `servers`, in this order — one + * atomic call for the raw-JSON editor, whose document IS the whole scope. + * Doing it client-side (delete every server, then re-create) lost everything + * whenever a later create was rejected. */ + replaceMcps: (scope: Scope, servers: Omit[]) => + json('/api/mcps', { + method: 'PUT', + body: JSON.stringify({ scope, servers }) + }), updateMcp: ( id: string, body: Partial> @@ -292,26 +306,44 @@ export const api = { deleteSkill: (id: string) => json(`/api/skills/${pid(id)}`, { method: 'DELETE' }), - // Memory — project-scoped items. Default project rejects (400). - listMemoryItems: (projectId: string) => - json(`/api/projects/${pid(projectId)}/memory/items`), - createMemoryItem: (projectId: string, content: string) => - json(`/api/projects/${pid(projectId)}/memory/items`, { - method: 'POST', - body: JSON.stringify({ content }) - }), - updateMemoryItem: (projectId: string, itemId: string, content: string) => - json( - `/api/projects/${pid(projectId)}/memory/items/${pid(itemId)}`, - { - method: 'PUT', - body: JSON.stringify({ content }) - } + // Memory — project-scoped items. 400s when the project has memory off. + // The vector card renders load failures itself (a misconfigured vector + // backend is a legible state, not a transient error), so it passes + // `{ silent: true }` to keep the global toast out of it. + listMemoryItems: (projectId: string, opts?: ApiCallOpts) => + json( + `/api/projects/${pid(projectId)}/memory/items`, + {}, + opts ), deleteMemoryItem: (projectId: string, itemId: string) => json(`/api/projects/${pid(projectId)}/memory/items/${pid(itemId)}`, { method: 'DELETE' }), + // Memory health: resolved embedder identity, why vector memory is unusable + // (machine-readable code), last background-ingest outcome. + getMemoryStatus: (projectId: string, opts?: ApiCallOpts) => + json( + `/api/projects/${pid(projectId)}/memory/status`, + {}, + opts + ), + // Start the vector store over with the current embedder (the remedy for an + // embedder mismatch); the old store is moved aside, never deleted. + rebuildMemory: (projectId: string) => + json(`/api/projects/${pid(projectId)}/memory/rebuild`, { + method: 'POST' + }), + + // Memory as ONE markdown document — file backend only (vector rejects with + // 400). Same store as the item endpoints above, viewed wholesale. + getMemoryDoc: (projectId: string, opts?: ApiCallOpts) => + json(`/api/projects/${pid(projectId)}/memory/doc`, {}, opts), + putMemoryDoc: (projectId: string, content: string) => + json(`/api/projects/${pid(projectId)}/memory/doc`, { + method: 'PUT', + body: JSON.stringify({ content }) + }), // Workspace files listWorkspaceFiles: (projectId: string, opts?: ApiCallOpts) => @@ -512,3 +544,22 @@ export const api = { postPresence: () => json<{ running: string[] }>('/api/presence', { method: 'POST' }) } + +/** + * Turn an API failure inside a route loader into a thrown `Response`. + * + * A raw `ApiError` cannot survive the SSR boundary: React Router serializes a + * loader error to the client as a plain Error, dropping both the class and the + * `status`. The error page would then render 404 on the server and "unexpected + * error" after hydration — a visible downgrade. A thrown Response carries its + * status across intact, so both sides agree. + */ +export async function orThrow(promise: Promise): Promise { + try { + return await promise + } catch (err) { + if (err instanceof ApiError) + throw new Response(err.message, { status: err.status }) + throw err + } +} diff --git a/webui/frontend/app/lib/designTokens.ts b/webui/frontend/app/lib/designTokens.ts index ce986e751..8bff36bb9 100644 --- a/webui/frontend/app/lib/designTokens.ts +++ b/webui/frontend/app/lib/designTokens.ts @@ -101,6 +101,11 @@ const light = { 4: '#f1f1fd', 5: '#dde8f7', 6: 'rgba(255, 255, 255, 0.7)', + // Loading-skeleton shimmer — see the dark counterpart. Translucent black so + // the two gradient stops actually differ (fill[2] and fill[3] are within + // 0.02 luminance of each other, which left the shimmer motionless). + skeleton: 'rgba(0, 0, 0, 0.06)', + skeletonShimmer: 'rgba(0, 0, 0, 0.15)', trans: 'rgba(0, 0, 0, 0.7)', trans1: 'rgba(0, 0, 0, 0.7)', orangered: '#fbf1f1', @@ -223,6 +228,13 @@ const dark = { 4: '#333150', 5: '#2d2b4d', 6: '#0a0a0a', + // Loading-skeleton shimmer (antd Skeleton gradient endpoints). Kept + // TRANSLUCENT so the contrast holds on every dark surface: the opaque + // fills above sit at #202020, which is within 1.05 contrast of the panel + // background (#1c1c1e) — a skeleton painted in them is invisible, and with + // fill[2] === fill[3] the shimmer had no gradient to animate either. + skeleton: 'rgba(255, 255, 255, 0.08)', + skeletonShimmer: 'rgba(255, 255, 255, 0.18)', trans: 'rgba(255, 255, 255, 0.7)', trans1: 'rgba(255, 255, 255, 0.7)', orangered: '#141414', diff --git a/webui/frontend/app/lib/dropFiles.ts b/webui/frontend/app/lib/dropFiles.ts new file mode 100644 index 000000000..21a2cdbb9 --- /dev/null +++ b/webui/frontend/app/lib/dropFiles.ts @@ -0,0 +1,98 @@ +/** + * Reading OS drag-and-drop payloads. + * + * `DataTransfer.files` is a trap for folder drops: a dropped directory shows up + * as a single bogus `File` (its name, the inode's size — 96 B on macOS, empty + * type) that cannot be read. Anything relying on it therefore renders a folder + * as if it were a small file, and uploading it fails or stores garbage. + * + * The real payload is behind `DataTransfer.items` + `webkitGetAsEntry()`, which + * exposes the FileSystem entry tree and lets us walk directories. This module is + * the single place that walk lives — every drop zone in the app goes through it. + */ + +/** One real file from a drop, with its path RELATIVE to the drop (a file dropped + * on its own is just its name; a file inside a dropped folder is prefixed with + * that folder, e.g. `my-skill/SKILL.md`). */ +export interface DroppedFile { + file: File + path: string +} + +/** One top-level dropped item, kept whole so callers can tell a folder drop from + * a file drop (a skill bundle is a folder; the folder's name names the skill). */ +export interface DroppedEntry { + name: string + isDirectory: boolean + files: DroppedFile[] +} + +async function readEntry( + entry: FileSystemEntry, + basePath: string, + out: DroppedFile[] +): Promise { + if (entry.isFile) { + const file = await new Promise((resolve, reject) => + (entry as FileSystemFileEntry).file(resolve, reject) + ).catch(() => null) + if (file) out.push({ file, path: basePath + file.name }) + return + } + if (!entry.isDirectory) return + const reader = (entry as FileSystemDirectoryEntry).createReader() + let batch: FileSystemEntry[] = [] + // readEntries hands back at most ~100 entries per call — loop until it's dry. + do { + batch = await new Promise((resolve) => + reader.readEntries(resolve, () => resolve([])) + ) + for (const child of batch) + await readEntry(child, `${basePath}${entry.name}/`, out) + } while (batch.length > 0) +} + +/** Every top-level dropped item with its files, directories walked recursively. + * + * Falls back to `getAsFile()` for browsers/items without the entry API, where a + * directory simply cannot be distinguished — it is then reported as a file, which + * is the best that platform allows. */ +export async function collectDroppedEntries( + dataTransfer: DataTransfer +): Promise { + const entries: DroppedEntry[] = [] + // Snapshot first: the items list is neutered once we await. + const handles = Array.from(dataTransfer.items) + .filter((item) => item.kind === 'file') + .map((item) => ({ + entry: item.webkitGetAsEntry?.() ?? null, + file: item.getAsFile() + })) + for (const { entry, file } of handles) { + if (entry) { + const files: DroppedFile[] = [] + await readEntry(entry, '', files) + entries.push({ + name: entry.name, + isDirectory: !!entry.isDirectory, + files + }) + } else if (file) { + entries.push({ + name: file.name, + isDirectory: false, + files: [{ file, path: file.name }] + }) + } + } + return entries +} + +/** Flat list of the real files in a drop — folders contribute their contents + * with folder-prefixed paths. For drop zones that just want "the files". */ +export async function collectDroppedFiles( + dataTransfer: DataTransfer +): Promise { + const entries = await collectDroppedEntries(dataTransfer) + return entries.flatMap((entry) => entry.files) +} diff --git a/webui/frontend/app/lib/editorLanguage.ts b/webui/frontend/app/lib/editorLanguage.ts new file mode 100644 index 000000000..5f0e0da1e --- /dev/null +++ b/webui/frontend/app/lib/editorLanguage.ts @@ -0,0 +1,153 @@ +/** + * File extension → Monaco language id. + * + * Single source of truth for every editor/preview surface (workspace rail, + * skill viewer, …) — the mapping used to be duplicated per component, which let + * the two drift (one knew `markdown`/`xml`, the other didn't). + * + * Ids are only listed when monaco actually ships a tokenizer for them + * (`monaco-editor/esm/vs/basic-languages/*`), so every entry here yields real + * syntax highlighting. A handful additionally get a full language service + * (diagnostics/completion) via a dedicated worker — see CodeEditor's + * MonacoEnvironment: json, css/scss/less, html/handlebars/razor, and + * typescript/javascript. Everything else is highlighting + the generic editor + * worker, which is all monaco offers for those languages. + */ +const LANGUAGE_BY_EXT: Record = { + // --- with a dedicated language service (worker) --- + json: 'json', + jsonc: 'json', + json5: 'json', + css: 'css', + scss: 'scss', + less: 'less', + html: 'html', + htm: 'html', + hbs: 'handlebars', + handlebars: 'handlebars', + ts: 'typescript', + mts: 'typescript', + cts: 'typescript', + tsx: 'typescript', + js: 'javascript', + mjs: 'javascript', + cjs: 'javascript', + jsx: 'javascript', + + // --- syntax highlighting only (no upstream worker exists) --- + md: 'markdown', + markdown: 'markdown', + mdx: 'mdx', + py: 'python', + pyi: 'python', + go: 'go', + rs: 'rust', + java: 'java', + kt: 'kotlin', + kts: 'kotlin', + swift: 'swift', + rb: 'ruby', + php: 'php', + cs: 'csharp', + c: 'cpp', + h: 'cpp', + cc: 'cpp', + cpp: 'cpp', + cxx: 'cpp', + hpp: 'cpp', + hh: 'cpp', + m: 'objective-c', + mm: 'objective-c', + scala: 'scala', + dart: 'dart', + lua: 'lua', + pl: 'perl', + pm: 'perl', + r: 'r', + jl: 'julia', + ex: 'elixir', + exs: 'elixir', + clj: 'clojure', + cljs: 'clojure', + fs: 'fsharp', + fsx: 'fsharp', + vb: 'vb', + pas: 'pascal', + sol: 'solidity', + proto: 'protobuf', + graphql: 'graphql', + gql: 'graphql', + sql: 'sql', + pgsql: 'pgsql', + sh: 'shell', + bash: 'shell', + zsh: 'shell', + fish: 'shell', + ps1: 'powershell', + psm1: 'powershell', + bat: 'bat', + cmd: 'bat', + yaml: 'yaml', + yml: 'yaml', + toml: 'ini', + ini: 'ini', + cfg: 'ini', + conf: 'ini', + properties: 'ini', + env: 'ini', + xml: 'xml', + svg: 'xml', + plist: 'xml', + xsd: 'xml', + tf: 'hcl', + tfvars: 'hcl', + hcl: 'hcl', + dockerfile: 'dockerfile', + rst: 'restructuredtext', + tcl: 'tcl', + st: 'st', + abap: 'abap', + apex: 'apex', + cls: 'apex', + coffee: 'coffee', + pug: 'pug', + jade: 'pug', + twig: 'twig', + liquid: 'liquid', + wgsl: 'wgsl', + sv: 'systemverilog', + svh: 'systemverilog', + txt: 'plaintext', + log: 'plaintext' +} + +/** Files whose LANGUAGE is decided by the whole filename, not an extension. */ +const LANGUAGE_BY_FILENAME: Record = { + dockerfile: 'dockerfile', + containerfile: 'dockerfile', + makefile: 'plaintext', + gemfile: 'ruby', + rakefile: 'ruby', + '.gitignore': 'plaintext', + '.dockerignore': 'plaintext', + '.npmrc': 'ini', + '.editorconfig': 'ini', + '.env': 'ini' +} + +/** + * Monaco language id for a workspace path. Falls back to `plaintext` so an + * unknown file still opens in the editor (highlighting off, editing intact). + */ +export function languageFor(path: string): string { + const name = (path.split('/').pop() ?? path).toLowerCase() + const byName = LANGUAGE_BY_FILENAME[name] + if (byName) return byName + // Suffixed variants of a well-known NAME: `Dockerfile.dev`, `.env.local`. + // Dotfiles keep their leading dot so `.env.local` still resolves via `.env`. + const token = name.split('.').filter(Boolean)[0] ?? '' + const lead = name.startsWith('.') ? `.${token}` : token + if (LANGUAGE_BY_FILENAME[lead]) return LANGUAGE_BY_FILENAME[lead] + const ext = name.includes('.') ? (name.split('.').pop() ?? '') : '' + return LANGUAGE_BY_EXT[ext] ?? 'plaintext' +} diff --git a/webui/frontend/app/lib/events.ts b/webui/frontend/app/lib/events.ts index 92ff8fef1..e4fbe6531 100644 --- a/webui/frontend/app/lib/events.ts +++ b/webui/frontend/app/lib/events.ts @@ -102,3 +102,26 @@ export function useOnSessionDone(callback: (sessionId: string) => void) { return () => window.removeEventListener('msa:session-done', handler) }, [callback]) } + +// ─── Session turn start ──────────────────────────────────────── + +/** Dispatch when a turn is SENT for a session (and for a brand-new chat, the + * moment the backend hands back its id). The mirror image of + * `dispatchSessionDone`: without it the sidebar spinner only appeared on the + * next heartbeat, up to a full poll interval after the user pressed send. */ +export function dispatchSessionStarted(sessionId: string) { + if (typeof window !== 'undefined') + window.dispatchEvent( + new CustomEvent('msa:session-started', { detail: sessionId }) + ) +} + +/** Listen for session-started events (payload = sessionId string). */ +export function useOnSessionStarted(callback: (sessionId: string) => void) { + useEffect(() => { + if (typeof window === 'undefined') return + const handler = (e: Event) => callback((e as CustomEvent).detail) + window.addEventListener('msa:session-started', handler) + return () => window.removeEventListener('msa:session-started', handler) + }, [callback]) +} diff --git a/webui/frontend/app/lib/locales/en.json b/webui/frontend/app/lib/locales/en.json index 7c3f4bac3..19b316b09 100644 --- a/webui/frontend/app/lib/locales/en.json +++ b/webui/frontend/app/lib/locales/en.json @@ -20,7 +20,8 @@ "confirmDeleteProject": "Are you sure you want to delete this project? This action cannot be undone.", "confirmDeleteSession": "Are you sure you want to delete this session? This action cannot be undone.", "confirmOk": "Delete", - "confirmCancel": "Cancel" + "confirmCancel": "Cancel", + "noSessions": "No chats yet — start one!" }, "chat": { "welcomeDesc": "Tell me what to do. I'll plan, call tools, and write artifacts to workspace.", @@ -42,21 +43,35 @@ "stepInvokeTool": "Call tool", "stepInvokeMcp": "Call MCP", "stepLoadSkill": "Load skill", + "stepSkillList": "List skills", + "stepSkillSearch": "Search skills", + "stepSkillCreate": "Create skill", + "stepSkillEdit": "Edit skill", + "stepSkillDelete": "Delete skill", + "stepSkillManage": "Manage skill", "stepTerminal": "Terminal", "stepFileRead": "Read", "stepFileWrite": "Wrote", "stepFileEdit": "Edited", + "stepFileReadAsk": "Read file", + "stepFileWriteAsk": "Write file", + "stepFileEditAsk": "Edit file", + "stepFileReading": "Reading", + "stepFileWriting": "Writing", + "stepFileEditing": "Editing", "stepBrowser": "Visit", "stepSearch": "Search", + "stepSearching": "Searching", "stepSearchFiles": "Search files", "searchedPages": "Found {n} pages", "stepMemory": "Update memory", "stepMemoryRead": "Read memory", "stepAuthTitle": "Authorization", - "authApprove": "Allow once", - "authApproveAlways": "Always allow", + "authApprove": "Run once", + "authApproveAlways": "Always run", "authReject": "Reject", "authRejected": "Authorization rejected", + "authExecuting": "Running", "authCancelled": "Authorization request cancelled (turn stopped).", "backToBottom": "Back to bottom", "detailArguments": "Arguments", @@ -77,7 +92,7 @@ "placeholder": "Ask me anything, or use a skill…", "noProject": "No project", "createProject": "Create project", - "modelPill": "Model", + "modelUnset": "No model selected", "mcpPill": "MCP", "skillPill": "Skills", "permFullAccess": "Full access", @@ -91,6 +106,7 @@ "runningTask": "Running task: ", "thinkingFiles": "Files", "send": "Send", + "modelRequired": "Select a model first", "stop": "Stop", "addFile": "Add file", "retryUpload": "Upload failed — click to retry", @@ -135,13 +151,21 @@ "memoryTitle": "Memory", "edit": "Edit", "delete": "Delete", - "empty": "Empty", "memoryDisabled": "Memory is disabled for this project.", "memoryEmpty": "No memories yet", - "addMemory": "New memory", - "addMemoryPlaceholder": "Type the memory, press Enter to create", + "memoryLoadFailed": "Could not load memories", + "memoryIngesting": "Updating memory…", + "memoryIngestFailed": "Memory update failed", + "memoryRebuild": "Rebuild", + "memoryRebuildConfirm": "Rebuild the memory store with the current embedding model? The old store is kept as a backup.", + "memoryRebuilt": "Memory store rebuilt", "deleteMemory": "Delete this memory?", - "filter": "Filter" + "memoryDocEmpty": "No memory yet", + "memoryDocDrawerTitle": "Edit memory", + "memoryDocHint": "Memory is kept as a single Markdown file the agent reads on every turn.", + "save": "Save", + "cancel": "Cancel", + "memorySaved": "Memory saved" }, "resources": { "add": "Add", @@ -154,7 +178,7 @@ "disabled": "Disabled", "save": "Save", "cancel": "Cancel", - "empty": "No MCP yet — click \"Add\" to get started.", + "mcpEmpty": "No MCP yet — click \"Add\" to get started.", "viaJson": "Configure via JSON", "reconnect": "Test Connection", "tryIt": "View", @@ -183,8 +207,10 @@ "hubProjectBadge": "project", "hubGlobalBadge": "global", "customTitle": "Custom add MCP server", + "editTitle": "Edit MCP server", "customConfirm": "Confirm", "customInvalid": "JSON is invalid — fix the syntax before submitting.", + "editSingleOnly": "Editing can only contain a single MCP server", "formTab": "Form", "jsonTab": "JSON", "typeLabel": "Type", @@ -197,12 +223,14 @@ }, "skillImport": { "localTitle": "Import from local", - "localDropHint": "Click to select files or drag files/folders here", - "localPickFile": "Select File", - "localPickFolder": "Select Folder", + "localModeUpload": "Upload folder", + "localModePath": "Enter a directory path", + "localUploadHint": "Pick a local skill folder — the whole directory is copied into the skills directory.", + "localDropHint": "Drop or click to choose a skill folder", + "localFolderOnly": "Drop a skill folder (a single file cannot name the skill)", "localFolderFiles": "files", "localPathPlaceholder": "/Users/you/.agents/skills/my-skill", - "localPathHint": "Enter a directory path that the backend process can read. The directory should contain SKILL.md.", + "localPathHint": "Enter a directory path the backend process can read — it is referenced in place, not copied.", "localFileReqTitle": "Requirements", "localFileReq3": "SKILL.md must declare the skill name and description as YAML front-matter.", "localUpload": "Import" @@ -260,7 +288,7 @@ "apiKeyPlaceholder": "Enter API key (ms-...)", "protocol": "Protocol", "builtinBadge": "Built-in", - "modelName": "model_name", + "modelName": "Model ID", "modelNameHint": "Provider-specific model id (e.g. deepseek-ai/DeepSeek-R1-052B).", "modelNamePlaceholder": "Search or enter a model id", "modelsLoading": "Loading models…", @@ -309,7 +337,23 @@ "memoryBackendLabel": "Default backend", "memoryBackendDesc": "Backend used for memory storage in new projects. Cannot be changed after a project is created.", "backendFile": "File", - "backendVector": "Vector DB" + "backendVector": "Vector DB", + "memoryLlmLabel": "Fact-extraction model", + "memoryLlmDesc": "Model that turns conversations into memories; defaults to the conversation model.", + "followConversationModel": "Follow conversation model", + "specificProvider": "Specific provider", + "memoryModelPlaceholder": "Select a model", + "memoryModelRequired": "Select an extraction model", + "memoryEmbedLabel": "Embedding model", + "memoryEmbedDesc": "When following the conversation provider, falls back to the local model if it serves no embeddings.", + "memoryEmbedModelRequired": "Enter an embedding model", + "memoryModelIncomplete": "Complete the memory model settings", + "embedFollowProvider": "Conversation provider", + "embedLocal": "Local (offline)", + "embedLocalDesc": "Runs offline on this machine; requires uv sync --extra local-embed (~220 MB first download).", + "memorySectionDesc": "Defaults for new projects; existing projects are unaffected.", + "memoryRecallLabel": "Recall count", + "memoryRecallDesc": "Related memories injected per turn (default 10)." }, "settings": { "title": "Agent settings", @@ -339,11 +383,20 @@ "locationLabel": "Choose project location", "locationPlaceholder": "/Users/you/Projects/my-project", "memoryTitle": "Memory", - "memoryDesc": "Enable memory by default in sessions" + "memoryDesc": "Enable memory by default in sessions", + "memoryBackendLabel": "Memory storage backend", + "backendFile": "File", + "backendVector": "Vector DB", + "memoryBackendHint": "Once memory is enabled with this backend, it can no longer be changed.", + "memoryBackendLockedHint": "Memory has been enabled before — the storage backend can no longer be changed.", + "locationHint": "Leave empty to use the default location (created under the data directory).", + "locationLockedHint": "The project location cannot be changed after creation.", + "embedChangeWarn": "Changing the embedding model requires a store rebuild (old store is kept as backup)." }, "errors": { "requestFailed": "Request failed", - "network": "Network error — please check your connection" + "network": "Network error — please check your connection", + "backHome": "Back to home" }, "pageTitle": { "newChat": "New chat", diff --git a/webui/frontend/app/lib/locales/zh.json b/webui/frontend/app/lib/locales/zh.json index 449b06cd3..78b8f31a6 100644 --- a/webui/frontend/app/lib/locales/zh.json +++ b/webui/frontend/app/lib/locales/zh.json @@ -20,7 +20,8 @@ "confirmDeleteProject": "确定删除该项目吗?此操作无法撤销。", "confirmDeleteSession": "确定删除该对话吗?此操作无法撤销。", "confirmOk": "删除", - "confirmCancel": "取消" + "confirmCancel": "取消", + "noSessions": "您还没有对话,快去对话吧~" }, "chat": { "welcomeDesc": "告诉我要做什么,我会拆解 plan、调用工具、把产物写到 workspace。", @@ -42,21 +43,35 @@ "stepInvokeTool": "调用工具", "stepInvokeMcp": "调用MCP", "stepLoadSkill": "加载技能", + "stepSkillList": "查看技能列表", + "stepSkillSearch": "搜索技能", + "stepSkillCreate": "创建技能", + "stepSkillEdit": "编辑技能", + "stepSkillDelete": "删除技能", + "stepSkillManage": "管理技能", "stepTerminal": "终端执行", "stepFileRead": "已读取", "stepFileWrite": "已写入", "stepFileEdit": "已编辑", + "stepFileReadAsk": "读取文件", + "stepFileWriteAsk": "写入文件", + "stepFileEditAsk": "编辑文件", + "stepFileReading": "正在读取", + "stepFileWriting": "正在写入", + "stepFileEditing": "正在编辑", "stepBrowser": "访问", "stepSearch": "搜索", + "stepSearching": "正在搜索", "stepSearchFiles": "搜索文件", "searchedPages": "搜索到 {n} 个网页", "stepMemory": "更新记忆", "stepMemoryRead": "读取记忆", "stepAuthTitle": "授权确认", - "authApprove": "本次同意", - "authApproveAlways": "始终同意", + "authApprove": "本次运行", + "authApproveAlways": "始终运行", "authReject": "拒绝", "authRejected": "已拒绝授权", + "authExecuting": "执行中", "authCancelled": "授权请求已取消(对话已停止)。", "backToBottom": "回到底部", "detailArguments": "调用参数", @@ -77,7 +92,7 @@ "placeholder": "可以问我任何问题或输入 / 使用技能", "noProject": "未指定", "createProject": "创建项目", - "modelPill": "模型", + "modelUnset": "未选择模型", "mcpPill": "MCP", "skillPill": "Skills", "permFullAccess": "完全授权", @@ -91,6 +106,7 @@ "runningTask": "正在执行任务:", "thinkingFiles": "文件列表", "send": "发送", + "modelRequired": "请先选择模型", "stop": "停止", "addFile": "添加文件", "retryUpload": "上传失败,点击重试", @@ -135,13 +151,21 @@ "memoryTitle": "记忆", "edit": "编辑", "delete": "删除", - "empty": "空", "memoryDisabled": "当前项目未开启记忆", "memoryEmpty": "暂无记忆", - "addMemory": "新建记忆", - "addMemoryPlaceholder": "输入记忆内容,按回车新建", + "memoryLoadFailed": "记忆读取失败", + "memoryIngesting": "记忆更新中…", + "memoryIngestFailed": "记忆更新失败", + "memoryRebuild": "重建", + "memoryRebuildConfirm": "用当前嵌入模型重建记忆库?旧库会保留为备份。", + "memoryRebuilt": "记忆库已重建", "deleteMemory": "确认删除此记忆?", - "filter": "筛选" + "memoryDocEmpty": "暂无记忆内容", + "memoryDocDrawerTitle": "编辑记忆", + "memoryDocHint": "记忆以一个 Markdown 文件保存,Agent 每轮对话都会读取它。", + "save": "保存", + "cancel": "取消", + "memorySaved": "记忆已保存" }, "resources": { "add": "添加", @@ -154,7 +178,7 @@ "disabled": "禁用", "save": "保存", "cancel": "取消", - "empty": "暂无 MCP —— 点击\"添加\"开始配置", + "mcpEmpty": "暂无 MCP —— 点击\"添加\"开始配置", "viaJson": "通过 JSON 配置", "reconnect": "测试连接", "tryIt": "查看", @@ -183,10 +207,12 @@ "hubProjectBadge": "项目", "hubGlobalBadge": "全局", "customTitle": "自定义添加 MCP Server", + "editTitle": "编辑 MCP Server", "customConfirm": "确定", "customInvalid": "JSON 格式不正确,请先修复后再提交", - "formTab": "表单添加", - "jsonTab": "JSON 添加", + "editSingleOnly": "编辑时只能包含一个 MCP Server", + "formTab": "表单", + "jsonTab": "JSON", "typeLabel": "类型", "urlLabel": "URL 链接", "commandLabel": "命令", @@ -197,12 +223,14 @@ }, "skillImport": { "localTitle": "从本地上传", - "localDropHint": "点击选择文件或将文件/文件夹拖拽到这里", - "localPickFile": "选择文件", - "localPickFolder": "选择文件夹", + "localModeUpload": "上传文件夹", + "localModePath": "填写目录路径", + "localUploadHint": "选择本地技能文件夹,整个目录将被复制到技能目录。", + "localDropHint": "拖放或点击选择技能文件夹", + "localFolderOnly": "请拖入技能文件夹(单个文件无法确定技能名称)", "localFolderFiles": "个文件", "localPathPlaceholder": "/Users/you/.agents/skills/my-skill", - "localPathHint": "输入后端进程可读取的目录路径;目录内应包含 SKILL.md。", + "localPathHint": "填写后端进程可读取的目录路径,服务端直接引用该目录,不复制文件。", "localFileReqTitle": "文件要求", "localFileReq3": "SKILL.md 包含以 YAML 格式编写的技能名称和描述", "localUpload": "导入" @@ -260,7 +288,7 @@ "apiKeyPlaceholder": "请输入 API Key(ms-...)", "protocol": "协议", "builtinBadge": "内置", - "modelName": "model_name", + "modelName": "模型 id", "modelNameHint": "供应商提供的模型 id(例如 deepseek-ai/DeepSeek-R1-052B)。", "modelNamePlaceholder": "搜索或输入模型 id", "modelsLoading": "模型加载中…", @@ -309,7 +337,23 @@ "memoryBackendLabel": "默认后端", "memoryBackendDesc": "新项目的记忆存储后端;项目创建后不可切换。", "backendFile": "文件", - "backendVector": "向量数据库" + "backendVector": "向量数据库", + "memoryLlmLabel": "事实抽取模型", + "memoryLlmDesc": "从对话中提取记忆条目所用的模型,默认跟随对话模型。", + "followConversationModel": "跟随对话模型", + "specificProvider": "指定供应商", + "memoryModelPlaceholder": "选择模型", + "memoryModelRequired": "请选择事实抽取模型", + "memoryEmbedLabel": "嵌入模型", + "memoryEmbedDesc": "跟随对话供应商时,若其不提供嵌入服务将改用本地模型。", + "memoryEmbedModelRequired": "请输入嵌入模型", + "memoryModelIncomplete": "请完善记忆模型配置", + "embedFollowProvider": "跟随对话供应商", + "embedLocal": "本地(离线)", + "embedLocalDesc": "本机离线运行;需先执行 uv sync --extra local-embed(首次下载约 220MB)。", + "memorySectionDesc": "以下为新建项目的默认值,不影响已有项目。", + "memoryRecallLabel": "召回条数", + "memoryRecallDesc": "每轮注入的相关记忆条数(默认 10)。" }, "settings": { "title": "智能体设置", @@ -339,11 +383,20 @@ "locationLabel": "指定项目地址", "locationPlaceholder": "/Users/你/Projects/my-project", "memoryTitle": "记忆开关", - "memoryDesc": "对话中默认开启记忆" + "memoryDesc": "对话中默认开启记忆", + "memoryBackendLabel": "记忆存储后端", + "backendFile": "文件", + "backendVector": "向量数据库", + "memoryBackendHint": "记忆存储后端一旦选择并启用记忆后将无法修改。", + "memoryBackendLockedHint": "已启用过记忆,存储后端不可再修改。", + "locationHint": "不指定则使用默认项目地址(数据目录下自动创建)。", + "locationLockedHint": "项目地址创建后不可修改。", + "embedChangeWarn": "更换嵌入模型需重建记忆库(旧库会保留备份)。" }, "errors": { "requestFailed": "请求失败", - "network": "网络错误,请检查您的网络连接" + "network": "网络错误,请检查您的网络连接", + "backHome": "回到首页" }, "pageTitle": { "newChat": "新对话", diff --git a/webui/frontend/app/lib/msaTheme.ts b/webui/frontend/app/lib/msaTheme.ts index 3642fa41d..8cce430b3 100644 --- a/webui/frontend/app/lib/msaTheme.ts +++ b/webui/frontend/app/lib/msaTheme.ts @@ -93,6 +93,12 @@ const componentTokens = { itemSelectedBg: light.bg[1], itemSelectedColor: light.text.brand1, borderRadiusSM: 6 + }, + // See the dark counterpart: the default gradient stops (fill[2] → fill[3]) are + // near-identical here too, so the shimmer never appeared to move. + Skeleton: { + gradientFromColor: light.fill.skeleton, + gradientToColor: light.fill.skeletonShimmer } } @@ -109,6 +115,15 @@ const darkComponentTokens = { itemSelectedBg: dark.bg[1], itemSelectedColor: dark.text.brand1, borderRadiusSM: 6 + }, + // Skeleton derives its colour from `colorFillContent`/`colorFill`, and the + // dark map above points both at fill[2]/fill[3] — the same #202020. That made + // loading skeletons invisible on dark panels (1.04 contrast against the + // #1c1c1e background) with a shimmer whose two gradient stops were identical. + // Translucent white keeps a stable contrast over any dark surface. + Skeleton: { + gradientFromColor: dark.fill.skeleton, + gradientToColor: dark.fill.skeletonShimmer } } diff --git a/webui/frontend/app/lib/pageTitle.ts b/webui/frontend/app/lib/pageTitle.ts index ec7421515..19a9c035b 100644 --- a/webui/frontend/app/lib/pageTitle.ts +++ b/webui/frontend/app/lib/pageTitle.ts @@ -24,7 +24,7 @@ export function metaDict( /** * Build a document title: context-specific parts first (most specific → * least), always suffixed with the product name, e.g. - * `修复登录 bug · 我的项目 · 魔搭 MS Agent`. Blank parts are dropped, so a + * `Fix login bug · My project · MS Agent`. Blank parts are dropped, so a * missing project/session name just shortens the title. */ export function pageTitle(dict: Dict, ...parts: (string | undefined)[]): string { diff --git a/webui/frontend/app/lib/presenceContext.tsx b/webui/frontend/app/lib/presenceContext.tsx index f335b115a..8ccbae2ac 100644 --- a/webui/frontend/app/lib/presenceContext.tsx +++ b/webui/frontend/app/lib/presenceContext.tsx @@ -10,7 +10,7 @@ import { import type { ReactNode } from 'react' import { useRevalidator } from 'react-router' import { api } from '~/lib/api' -import { useOnSessionDone } from '~/lib/events' +import { useOnSessionDone, useOnSessionStarted } from '~/lib/events' /** * Live running-session state poll. @@ -28,6 +28,16 @@ import { useOnSessionDone } from '~/lib/events' */ const HEARTBEAT_MS = 10_000 +/** + * How long a locally-started turn is trusted before the server has to confirm + * it. Sending a message marks the session running IMMEDIATELY (the heartbeat is + * far too coarse for that feedback), but the request can also fail without ever + * producing a `done` frame — so the optimistic mark is dropped once this window + * passes without the server reporting the session as running. Comfortably longer + * than one heartbeat so a normal turn is confirmed well before it expires. + */ +const OPTIMISTIC_TTL_MS = 30_000 + interface PresenceValue { /** Ids of sessions with a turn currently in flight. */ running: ReadonlySet @@ -41,6 +51,12 @@ export function PresenceProvider({ children }: { children: ReactNode }) { const revalidator = useRevalidator() const revalidateRef = useRef(revalidator.revalidate) revalidateRef.current = revalidator.revalidate + // Sessions this tab just started, with the instant they were marked. Merged + // into every heartbeat result so a turn the server hasn't picked up yet keeps + // its spinner instead of flickering off on the next poll. + const optimisticRef = useRef>(new Map()) + // Lets the session-started handler kick a poll right away. + const beatRef = useRef<() => void>(() => {}) useEffect(() => { let alive = true @@ -48,21 +64,33 @@ export function PresenceProvider({ children }: { children: ReactNode }) { try { const res = await api.postPresence() if (!alive) return - const next = new Set(res.running) + // Expire stale optimistic marks (a failed request never reports done). + const now = Date.now() + const optimistic = optimisticRef.current + for (const [sid, at] of optimistic) { + if (now - at > OPTIMISTIC_TTL_MS) optimistic.delete(sid) + } + const next = new Set([...res.running, ...optimistic.keys()]) const prev = prevRef.current + const changed = + next.size !== prev.size || [...next].some((id) => !prev.has(id)) + // Only publish a CHANGED set. Re-publishing an identical one still hands + // every consumer a new Set identity, which re-runs their effects — the + // session view's re-attach effect then aborted and reopened its live SSE + // (plus a plan re-read) on every single beat. + if (!changed) return prevRef.current = next setRunning(next) // Any running-set change revalidates route data: a session ENTERING // the set may be brand-new (started from the home page — the sidebar // doesn't list it until loaders re-run), and one LEAVING it means its // background answer is ready for an open session view / flag clear. - const changed = - next.size !== prev.size || [...next].some((id) => !prev.has(id)) - if (changed) revalidateRef.current() + revalidateRef.current() } catch { // Offline/unreachable backend: keep beating; the next success resyncs. } } + beatRef.current = () => void beat() beat() const timer = setInterval(beat, HEARTBEAT_MS) return () => { @@ -71,17 +99,42 @@ export function PresenceProvider({ children }: { children: ReactNode }) { } }, []) + // A turn was just sent from THIS tab: show the spinner now rather than up to + // HEARTBEAT_MS later, and poll immediately so the server view catches up. + const handleStarted = useCallback((sid: string) => { + if (!sid) return + optimisticRef.current.set(sid, Date.now()) + setRunning((prev) => (prev.has(sid) ? prev : new Set(prev).add(sid))) + // Keep the heartbeat's diff baseline equal to what is rendered, so the next + // beat compares against reality (it now skips publishing when unchanged). + if (!prevRef.current.has(sid)) { + prevRef.current = new Set(prevRef.current).add(sid) + } + beatRef.current() + }, []) + useOnSessionStarted(handleStarted) + // When a turn finishes in THIS tab (done frame), immediately remove the // session from the running set so the spinner disappears without waiting // for the next heartbeat. const handleDone = useCallback( (sid: string) => { + // Drop the optimistic mark too, or the next heartbeat would re-add it. + optimisticRef.current.delete(sid) setRunning((prev) => { if (!prev.has(sid)) return prev const next = new Set(prev) next.delete(sid) return next }) + // Baseline mirrors what's rendered (see handleStarted). Dropping it here + // also means a session the SERVER still reports as running gets its + // spinner back on the next beat — that beat now counts as a change. + if (prevRef.current.has(sid)) { + const next = new Set(prevRef.current) + next.delete(sid) + prevRef.current = next + } }, [] ) diff --git a/webui/frontend/app/lib/theme.tsx b/webui/frontend/app/lib/theme.tsx index 3f707b452..78062bd10 100644 --- a/webui/frontend/app/lib/theme.tsx +++ b/webui/frontend/app/lib/theme.tsx @@ -50,6 +50,9 @@ export function ThemeProvider({ // Keep the class in sync — needed both for the OS-pref upgrade above // and any user toggle. The server-rendered class is set in root.tsx Layout. + // This class also drives `color-scheme` (app.css `html` / `html.dark`), which + // themes browser-drawn UI (scrollbars, form controls, native pickers) — so it + // must stay a class on , not move to a data attribute or inline style. useEffect(() => { if (typeof document === "undefined") return; document.documentElement.classList.toggle("dark", theme === "dark"); diff --git a/webui/frontend/app/lib/types.ts b/webui/frontend/app/lib/types.ts index 3bccd3f76..9b24aa961 100644 --- a/webui/frontend/app/lib/types.ts +++ b/webui/frontend/app/lib/types.ts @@ -17,6 +17,18 @@ export interface Project { is_default: boolean memory_enabled: boolean memory_backend: MemoryBackend + /** True once memory has been saved as enabled at least once: the backend + * choice is frozen from then on (it decides the on-disk storage layout). + * Toggling `memory_enabled` itself stays allowed. */ + memory_backend_locked: boolean + /** Project-owned memory-model group (materialized from global defaults at + * creation; global changes never touch existing projects). */ + memory_llm_provider_id: string | null + memory_llm_model: string | null + memory_embed_mode: 'provider' | 'local' + memory_embed_provider_id: string | null + memory_embed_model: string | null + memory_recall_top_k: number | null mcp_auto_attach: boolean skill_auto_attach: boolean permission_mode: PermissionMode @@ -147,6 +159,52 @@ export interface MemoryItem { updated_at: string } +/** The whole file-backend memory document (`MEMORY.md`). Only meaningful when + * `memory_backend === 'file'`, where memory IS one markdown file: the UI + * previews and edits it as a document instead of line-by-line items. */ +export interface MemoryDoc { + project_id: string + content: string + updated_at: string +} + +/** Which embedding model a vector project's store runs on. */ +export interface MemoryEmbedderInfo { + mode: 'provider' | 'local' + provider: string | null + model: string | null + dimension: number | null + /** Set when the default resolution had to fall back (e.g. the conversation + * provider serves no embeddings) — shown verbatim to explain the choice. */ + fallback_reason: string | null +} + +/** Why vector memory is unusable right now; `code` picks the remedy the UI + * offers (a rebuild button for `embedder_mismatch`, an install hint for + * `local_missing`). */ +export interface MemoryErrorInfo { + code: 'embedder_mismatch' | 'embed_unavailable' | 'local_missing' | string + message: string +} + +/** Last background-ingest outcome of the live runtime. */ +export interface MemoryIngestInfo { + state: 'idle' | 'scheduled' | 'running' | 'ok' | 'error' | string + at: string | null + count: number | null + error: string | null + pending: number +} + +export interface MemoryStatus { + project_id: string + backend: 'file' | 'vector' + embedder: MemoryEmbedderInfo | null + error: MemoryErrorInfo | null + ingest: MemoryIngestInfo | null + local_embed_available: boolean +} + export interface WorkspaceFile { project_id: string path: string @@ -203,6 +261,14 @@ export interface AgentSettings { default_model_id: string | null default_memory_enabled: boolean default_memory_backend: MemoryBackend + /** Vector-memory model choices. All null = follow the conversation model; + * explicit values pin extraction / embeddings independently of chat. */ + memory_llm_provider_id: string | null + memory_llm_model: string | null + memory_embed_mode: 'provider' | 'local' + memory_embed_provider_id: string | null + memory_embed_model: string | null + memory_recall_top_k: number | null global_mcp_auto_attach: boolean global_skill_auto_attach: boolean } diff --git a/webui/frontend/app/lib/workspaceFiles.tsx b/webui/frontend/app/lib/workspaceFiles.tsx index 5e11b7e45..f3fe50300 100644 --- a/webui/frontend/app/lib/workspaceFiles.tsx +++ b/webui/frontend/app/lib/workspaceFiles.tsx @@ -82,5 +82,24 @@ export function useWorkspaceFileSet(): Set | null { export function useFileExists(path: string, serverBaked: boolean): boolean { const fileSet = useWorkspaceFileSet() if (!path || fileSet === null) return serverBaked - return fileSet.has(path) + if (fileSet.has(path)) return true + // Absence is NOT proof of deletion: the listing is a curated view, not a + // full inventory — the backend deliberately hides framework internals + // (`sessions/`, `.ms_agent/snapshots`, machine-format memory dumps). Every + // such file the agent legitimately reads would otherwise render as + // "deleted". + // + // Rather than mirror the backend's hide rules here (they would drift), only + // let the set contradict the server for a directory it demonstrably + // enumerated: if some sibling shares this path's parent, the directory is + // covered and a missing entry really means gone. + const slash = path.lastIndexOf('/') + const parent = slash === -1 ? '' : path.slice(0, slash + 1) + for (const known of fileSet) { + if (known === path) continue + const knownSlash = known.lastIndexOf('/') + const knownParent = knownSlash === -1 ? '' : known.slice(0, knownSlash + 1) + if (knownParent === parent) return false // directory covered → truly gone + } + return serverBaked } diff --git a/webui/frontend/app/root.tsx b/webui/frontend/app/root.tsx index 26c68ae39..9173df9b8 100644 --- a/webui/frontend/app/root.tsx +++ b/webui/frontend/app/root.tsx @@ -15,11 +15,13 @@ import { import './app.css' import { NProgressHandler } from '~/components/common/NProgressHandler' -import { type ApiError, registerApiErrorReporter } from '~/lib/api' +import { ErrorState } from '~/components/common/ErrorState' +import { ApiError, registerApiErrorReporter } from '~/lib/api' import { getDesignTokenStyleContent } from '~/lib/designTokens' import { LANG_COOKIE, dictFor, type Lang, LangProvider, useT } from '~/lib/i18n' import { getMsaAntdTheme, msaModalProps } from '~/lib/msaTheme' import { THEME_COOKIE, type Theme, ThemeProvider, useTheme } from '~/lib/theme' +import { MsaButton } from './components/common/MsaButton' interface RootData { initialTheme: Theme @@ -145,24 +147,51 @@ export default function App() { export function ErrorBoundary() { const error = useRouteError() - const title = isRouteErrorResponse(error) - ? `${error.status} ${error.statusText}` - : 'Unexpected error' - const detail = - isRouteErrorResponse(error) && typeof error.data === 'string' + const { t } = useT() + const routeError = isRouteErrorResponse(error) + // A loader that let an API failure propagate carries the real HTTP status on + // the ApiError — without reading it, a missing project/session would show no + // status at all when it is plainly a 404. + const apiStatus = error instanceof ApiError ? error.status : undefined + const status = routeError ? error.status : apiStatus + // The status code IS the headline. A client-side exception carries no status, + // so it falls back to the error's OWN name (`TypeError`) rather than a phrase + // we made up — same principle as the description below. + const code = status + ? String(status) + : error instanceof Error + ? error.name + : undefined + // The server's own message is the explanation — it is the only text that knows + // what actually failed. Inventing a per-status sentence here would replace + // "project not found" with something vaguer. + const description = routeError + ? typeof error.data === 'string' && error.data ? error.data - : error instanceof Error - ? error.message - : 'Something went wrong.' + : error.statusText + : error instanceof Error + ? error.message + : String(error ?? '') return ( -
-
-

- {title} -

-

{detail}

-
-
+ — with `href` antd + // renders an , whose own color rule beats the variant's `text-white` + // and leaves dark text on the dark primary fill. + { + window.location.href = '/' + }} + > + {t.errors.backHome} + + } + /> ) } diff --git a/webui/frontend/app/routes/home.tsx b/webui/frontend/app/routes/home.tsx index e093003a3..f0f3a2a82 100644 --- a/webui/frontend/app/routes/home.tsx +++ b/webui/frontend/app/routes/home.tsx @@ -1,3 +1,4 @@ +import { useLocation } from 'react-router' import { ChatView } from '~/components/chat/ChatView' import { metaDict, pageTitle } from '~/lib/pageTitle' import type { Route } from './+types/home' @@ -11,5 +12,12 @@ export function meta({ matches }: Route.MetaArgs) { } export default function Home() { - return + // Keyed on `location.key` for the same reason as the project "new chat" route: + // sending the first message rewrites the address bar to /sessions/ via + // `history.replaceState` (no router navigation, so the stream survives), which + // leaves the router believing it is still on "/". Clicking "new chat" then + // navigates to "/" — same pathname, component reused, old conversation still + // on screen. A fresh `location.key` per navigation remounts it clean. + const location = useLocation() + return } diff --git a/webui/frontend/app/routes/project-detail.tsx b/webui/frontend/app/routes/project-detail.tsx index 09df6d6ee..682997faa 100644 --- a/webui/frontend/app/routes/project-detail.tsx +++ b/webui/frontend/app/routes/project-detail.tsx @@ -2,7 +2,7 @@ import { useLoaderData, useRevalidator } from 'react-router' import { useState } from 'react' import { ProjectOverviewView } from '~/components/project/ProjectOverviewView' import { NewProjectModal } from '~/components/project/NewProjectModal' -import { api } from '~/lib/api' +import { api, orThrow } from '~/lib/api' import type { Project } from '~/lib/types' import { metaDict, pageTitle } from '~/lib/pageTitle' import type { Route } from './+types/project-detail' @@ -18,7 +18,8 @@ export function meta({ loaderData, matches }: Route.MetaArgs) { export async function loader({ params }: Route.LoaderArgs) { const projectId = params.projectId as string const [project, sessions] = await Promise.all([ - api.getProject(projectId), + // An unknown id must surface as a 404 page, not "unexpected error". + orThrow(api.getProject(projectId)), api.listSessions(projectId) ]) return { project, sessions } diff --git a/webui/frontend/app/routes/project-new-session.tsx b/webui/frontend/app/routes/project-new-session.tsx index ef11b23ed..235726994 100644 --- a/webui/frontend/app/routes/project-new-session.tsx +++ b/webui/frontend/app/routes/project-new-session.tsx @@ -1,6 +1,6 @@ -import { useLoaderData } from 'react-router' +import { useLoaderData, useLocation } from 'react-router' import { ChatView } from '~/components/chat/ChatView' -import { api } from '~/lib/api' +import { api, orThrow } from '~/lib/api' import { metaDict, pageTitle } from '~/lib/pageTitle' import type { Route } from './+types/project-new-session' @@ -13,11 +13,25 @@ export function meta({ loaderData, matches }: Route.MetaArgs) { } export async function loader({ params }: Route.LoaderArgs) { - const project = await api.getProject(params.projectId as string) + // An unknown project id must surface as a 404 page, not "unexpected error". + const project = await orThrow(api.getProject(params.projectId as string)) return { project } } export default function ProjectNewSessionPage() { const { project } = useLoaderData() - return + // Keyed on `location.key` so hitting "new chat" while ALREADY on this route + // starts a genuinely empty chat. + // + // Once the first message is sent, ChatPanel swaps the address bar to + // /sessions/ with `history.replaceState` — deliberately not a router + // navigation, so the stream isn't cut. The router therefore still considers + // itself on /new. A later "new chat" click navigates to the same pathname, so + // this component is reused and kept its finished conversation on screen — the + // button looked dead. A same-path navigation does mint a fresh `location.key` + // (unlike the pathname), which remounts ChatView and clears that state. + const location = useLocation() + return ( + + ) } diff --git a/webui/frontend/app/routes/project-session.tsx b/webui/frontend/app/routes/project-session.tsx index 1d9852a02..805618ee1 100644 --- a/webui/frontend/app/routes/project-session.tsx +++ b/webui/frontend/app/routes/project-session.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { useLoaderData } from 'react-router' import { ChatView } from '~/components/chat/ChatView' -import { api } from '~/lib/api' +import { ApiError, api, orThrow } from '~/lib/api' import { historyToAgentMessages } from '~/lib/agentProvider' import { metaDict, pageTitle } from '~/lib/pageTitle' import type { Route } from './+types/project-session' @@ -24,10 +24,20 @@ export function meta({ loaderData, matches }: Route.MetaArgs) { export async function loader({ params }: Route.LoaderArgs) { const sessionId = params.sessionId as string const [project, session, messages, plan, artifacts] = await Promise.all([ - api.getProject(params.projectId as string), + // An unknown project id must surface as a 404 page, not "unexpected error". + orThrow(api.getProject(params.projectId as string)), // Whether a turn is in flight (running in the background): drives an // immediate live re-attach instead of a blank assistant area. - api.getSession(sessionId, { silent: true }).catch(() => null), + // + // A 404 here means the URL names a session that does not exist — there is no + // conversation to show, so it becomes the 404 page rather than an empty one. + // Any OTHER failure stays best-effort: a transient blip must not replace a + // readable session with an error page. + api.getSession(sessionId, { silent: true }).catch((err) => { + if (err instanceof ApiError && err.status === 404) + throw new Response(err.message, { status: 404 }) + return null + }), // History echo is best-effort: a fresh/unknown session yields an empty list // rather than blocking the page. api.listSessionMessages(sessionId, { silent: true }).catch(() => []), diff --git a/webui/frontend/app/routes/settings/appearance.tsx b/webui/frontend/app/routes/settings/appearance.tsx index 97bfa1350..df3237a38 100644 --- a/webui/frontend/app/routes/settings/appearance.tsx +++ b/webui/frontend/app/routes/settings/appearance.tsx @@ -17,7 +17,7 @@ export default function AppearanceSettings() { return (
- {/* 外观 */} + {/* Appearance */}
{t.settings.appearanceTheme} @@ -38,7 +38,7 @@ export default function AppearanceSettings() {
- {/* 语言 */} + {/* Language */}
{t.settings.appearanceLanguage} diff --git a/webui/frontend/app/routes/settings/models.tsx b/webui/frontend/app/routes/settings/models.tsx index 7ff0080fb..5c1510020 100644 --- a/webui/frontend/app/routes/settings/models.tsx +++ b/webui/frontend/app/routes/settings/models.tsx @@ -97,6 +97,17 @@ export default function ModelsSettings() { [models, settings?.default_provider_id] ) + // The settings pointer survives deleting the model it names, so it can address + // a model that no longer exists. Resolve it against the options and treat an + // unresolvable pointer as "nothing selected". + const resolvedDefaultModelId = useMemo( + () => + defaultModelOptions.some((o) => o.value === settings?.default_model_id) + ? (settings?.default_model_id ?? undefined) + : undefined, + [defaultModelOptions, settings?.default_model_id] + ) + return (
{/* Default model picker */} @@ -126,7 +137,12 @@ export default function ModelsSettings() { {t.settings.model}