From 4c05e88c521ecc460f314dfbeb72b1bfbdccd173 Mon Sep 17 00:00:00 2001 From: Wenchao An Date: Wed, 30 Sep 2026 23:09:05 +0800 Subject: [PATCH] feat: add inline @ references with multiple skill activation (#6063) * feat(frontend): unify composer references with an @ picker * feat: edit references inline and activate multiple skills * docs: describe inline references and multiple skill selection * fix: prevent sent drafts from returning and repair CI checks * fix: reconcile inline references and address review feedback * fix: preserve references through discovery errors and polish --- README.md | 4 +- .../deerflow/agents/middlewares/AGENTS.md | 43 +- .../skill_activation_middleware.py | 94 +- .../skill_tool_policy_middleware.py | 8 +- .../deerflow/runtime/secret_context.py | 22 + backend/tests/test_inline_skill_references.py | 234 +++++ .../test_skill_tool_policy_middleware.py | 27 + docs/images/composer-mentions.png | Bin 0 -> 54359 bytes frontend/AGENTS.md | 17 + .../[agent_name]/chats/[thread_id]/page.tsx | 7 +- .../components/workspace/chats/chat-page.tsx | 19 +- .../components/workspace/input-box-helpers.ts | 11 +- .../src/components/workspace/input-box.tsx | 904 +++++++++++++++--- .../workspace/mentions/inline-references.ts | 210 ++++ .../workspace/mentions/mention-picker.tsx | 420 ++++++++ .../components/workspace/mentions/query.ts | 14 + .../components/workspace/slash-skill-chip.tsx | 11 +- frontend/src/core/features/hooks.ts | 7 +- frontend/src/core/i18n/locales/en-US.ts | 20 + frontend/src/core/i18n/locales/types.ts | 19 + frontend/src/core/i18n/locales/zh-CN.ts | 20 + frontend/src/core/projects/types.ts | 3 + frontend/src/core/skills/hooks.ts | 4 +- frontend/src/core/threads/composer-draft.ts | 33 +- frontend/src/core/threads/hooks.ts | 2 + frontend/tests/e2e/chat.spec.ts | 32 +- frontend/tests/e2e/mentions.spec.ts | 304 ++++++ ...put-box-agent-skill-hydration.dom.test.tsx | 12 +- .../workspace/input-box-helpers.test.ts | 11 + .../workspace/input-box-mentions.dom.test.tsx | 742 ++++++++++++++ ...nput-box-skill-suggestion-ime.dom.test.tsx | 16 +- .../mentions/inline-references.test.ts | 119 +++ .../workspace/mentions/query.test.ts | 36 + .../unit/core/features/hooks.dom.test.tsx | 69 ++ 34 files changed, 3284 insertions(+), 210 deletions(-) create mode 100644 backend/tests/test_inline_skill_references.py create mode 100644 docs/images/composer-mentions.png create mode 100644 frontend/src/components/workspace/mentions/inline-references.ts create mode 100644 frontend/src/components/workspace/mentions/mention-picker.tsx create mode 100644 frontend/src/components/workspace/mentions/query.ts create mode 100644 frontend/tests/e2e/mentions.spec.ts create mode 100644 frontend/tests/unit/components/workspace/input-box-mentions.dom.test.tsx create mode 100644 frontend/tests/unit/components/workspace/mentions/inline-references.test.ts create mode 100644 frontend/tests/unit/components/workspace/mentions/query.test.ts create mode 100644 frontend/tests/unit/core/features/hooks.dom.test.tsx diff --git a/README.md b/README.md index 0b0c011787..fbcfd4a1f3 100644 --- a/README.md +++ b/README.md @@ -1486,7 +1486,9 @@ The Web UI composer can polish draft input before sending. The rewrite runs as a When the agent asks for clarification, the Web UI shows the structured response card but keeps the normal composer available. Users can complete the card or send a free-form chat message to bypass it; that message closes the latest pending clarification and becomes the agent's next input. Accompanying answer text stays outside the execution steps panel, and answering the request keeps previously completed text in the conversation while the agent continues. -Unsent Web UI composer drafts survive page reloads and switching between conversations within the same browser tab. Drafts are isolated by user, agent, and conversation, include a selected slash skill when present, and are cleared once a send is accepted. Attachments and quoted conversation context are intentionally not persisted. +Unsent Web UI composer drafts survive page reloads and switching between conversations within the same browser tab. Drafts are isolated by user, agent, and conversation, include the selected skill and explicit conversation references, and are cleared once a send is accepted. Project documents already attached to the thread also survive reloads; browser-local uploads and quoted message excerpts are not persisted. + +The Web UI composer supports `@` references at the cursor or through the `@` button. Search enabled skills, documents in the current project, and other conversations in a grouped picker; selected references appear inline alongside the draft text and can be removed with normal editing. Multiple skills can be selected in one message (up to 16 unique skills); selecting a checked skill again removes it, with each selection checked against the current user registry and agent allowlist. Project files are attached only after ingestion succeeds; conversation references require the `read_conversation` capability and retain its per-run limit. If capability discovery fails, saved conversation references remain intact and sending waits for a successful retry. Polishing preserves complete reference labels when instructions are reordered. The file group also offers local uploads. `/goal`, `/compact`, and legacy `/skill-name` input remain supported. The Web UI composer also supports browser-based voice dictation when the browser exposes the Web Speech API. The microphone button transcribes speech into the local draft only; DeerFlow receives only the transcribed text, while audio handling is delegated to the browser or operating system speech-recognition service according to that environment's policy. Users can review or edit the text before sending. diff --git a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md index aa1817688f..21b64994d1 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md +++ b/backend/packages/harness/deerflow/agents/middlewares/AGENTS.md @@ -19,31 +19,22 @@ stay `in_progress`. Never infer status from reply text. Assembly order: `tool_error_handling_middleware.py::_build_runtime_middlewares` (exposed as `build_lead_runtime_middlewares`), then `../lead_agent/agent.py::build_middlewares` appends lead-only entries. Optional entries require their config/runtime condition. -**Message provenance.** At injection/rewrite, always stamp `additional_kwargs` -via `deerflow_extension_api.provenance.provenance_kwargs()`: -`deerflow_content_kind`, `deerflow_producer_kind`, optional -`deerflow_producer_entity_id`. All are server-owned inbound metadata; stamp even -without observers, since downstream cannot recover producers. Producers: -DynamicContext (reminder/memory), DurableContext (contract/data), +**Message provenance.** Stamp injected/rewritten messages with +`provenance_kwargs()` from `deerflow_extension_api.provenance`: server-owned +`deerflow_content_kind`, `deerflow_producer_kind`, and optional entity ID, even +without observers. Producers: DynamicContext, DurableContext, SystemMessageCoalescing, ViewImage, SkillActivation. Summarization/Title use -`SystemOperationKind.SUMMARIZATION`/`.TITLE` model-call attribution; summaries -enter via DurableContext's stamped `durable_context_data`, not separate -messages. Memory only queues extraction; recall uses DynamicContext's -`dynamic_context_memory` stamp. +`SystemOperationKind.SUMMARIZATION`/`.TITLE`; summaries enter via DurableContext's +`durable_context_data`, memory recall via DynamicContext's +`dynamic_context_memory`. Memory only queues extraction. -**Middleware self-description.** Behaviour-configurable middleware implements -`release_policy_parameters() -> dict[str, object]` (duck-typed -`deerflow_extension_api.release.ReleasePolicyProvider`, no base class). -Use JSON-serialisable values and `canonical_hash` for long text, not prompt -copies. `collect_release_policies()` gathers stack declarations; update them -alongside every behaviour-affecting field. -Summarization declares enabled `task_continuity` retention settings (otherwise -`None`). DurableContext declares its normalized skills root, sorted read-tool -names and continuity switch, so each capture/injection policy affects assembly -identity without depending on private-field probing. -Continuity history readers share shape validation, including the capture failure -path and DurableContext rendering, so malformed persisted metadata cannot abort -ordinary compaction or a model call. +**Self-description.** Configurable middleware exposes JSON-serialisable +`release_policy_parameters()` (`ReleasePolicyProvider`, duck typed). Update +`collect_release_policies()` declarations with behavior; use `canonical_hash` +for long text. Summarization declares enabled task-continuity retention or None; +DurableContext declares normalized skills root, sorted read tools and continuity +switch. History readers, including capture failures, validate persisted metadata +so malformed values cannot abort compaction/model calls. **Removing tool calls.** Use `clone_ai_message_with_tool_calls`, not a bare `tool_calls` update: adapters resend stale `content` tool-call blocks, which @@ -131,3 +122,9 @@ Before changing a later authorization phase, read the [authorization RFC](../../ 37. **ModelLengthFinishReasonMiddleware** - Match stamps `stop_reason=model_length_capped` and ends tool loop. Suppresses calls, appends notice even with partial text, and stamps `model_length_termination` so downstream guards stand down. Preserves content blocks. 38. **SafetyFinishReasonMiddleware** - *(optional, if `safety_finish_reason.enabled`)* Suppresses tool execution when the provider safety-terminated the response (e.g. `finish_reason=content_filter`); registered after terminal-response/custom/configured middlewares so LangChain's reverse-order `after_model` dispatch runs it first 39. **ClarificationMiddleware** - Intercepts `ask_clarification`, writes a readable `ToolMessage.content` fallback plus a structured `ToolMessage.artifact.human_input` payload, and interrupts via `Command(goto=END)` (must be last). `after_model` drops same-turn sibling tool calls so they cannot run before the user answers; a malformed `ask_clarification` parked on `invalid_tool_calls` is the same stop signal. `disable_clarification` runs keep the siblings. Payloads are versioned — legacy `free_text`/`choice_with_other` stay `version: 1`; the v2 `form` mode (from `fields`) is `version: 2` so older frontends reject it and fall back to plain text. Field normalization is deterministic and lives in the middleware (it short-circuits before tool execution, so tool-arg typing gives no runtime validation), and it is atomic: any structurally broken entry — non-dict, bad/duplicate name, a name colliding with a JS `Object.prototype` member (`__proto__`/`constructor`), or exceeding the caps (16 fields / 24 options per field / 200 chars per text / `MAX_FORM_SERIALIZED_BYTES` = 16KB UTF-8, the per-item caps alone admitting forms whose IM text fallback overruns channel limits) — degrades the whole form to the legacy option/free-text modes, so a card never renders "complete" while missing a field. Benign issues degrade locally (unknown types — incl. unhashable JSON like `type: []`, which must not raise from the membership probe — and option-less selects become `text`); options are trimmed/deduped with blanks dropped (form- and top-level) since the frontend rejects blank labels. XML-to-dict option payloads are recursively flattened from dict/list containers in source order, scalar leaves kept, residual XML tags stripped before that trimming. Checkboxes are booleans defaulting to "no"; `required` on one means consent semantics. The response protocol is unchanged (v1 `text`/`option`): form cards submit a text summary as `response_kind: "text"`, so journal persistence needs no new allowlist entries. `RunJournal` reconciles visible, unpersisted `ToolMessage`s for current-run lead-agent calls at the next lead-agent `on_chat_model_start` after consumption, or at successful root `on_chain_end`. This preserves any middleware short-circuit, including blocked writes, across later model errors and checkpoint compaction (#4666). `_remember_current_run_tool_calls` excludes subagents; their results stay in `subagent.step`. Human Input Card replies are `hide_from_ui` `HumanMessage`s with `additional_kwargs.human_input_response`; `RunJournal` persists only allowlisted hidden sources (currently `ask_clarification`) as `llm.human.input`. + +Inline `skill_references` accepts 1–16 names; empty lists retain slash/plain flow. +Resolve the whole batch through user storage, enabled state and agent allowlist +before activation; reject any invalid entry. Keep bodies in escaped HumanMessage +context with task text once. Authenticated paths feed secrets/tool policy; record usage per skill. +Legacy slash syntax is unchanged. diff --git a/backend/packages/harness/deerflow/agents/middlewares/skill_activation_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/skill_activation_middleware.py index 0f8b97f37b..40d646e165 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/skill_activation_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/skill_activation_middleware.py @@ -5,11 +5,12 @@ from __future__ import annotations import asyncio import hashlib import html +import json import logging import posixpath import uuid from collections.abc import Awaitable, Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from typing import TYPE_CHECKING, override @@ -18,7 +19,7 @@ from langchain.agents.middleware import AgentMiddleware from langchain.agents.middleware.types import ModelRequest, ModelResponse from langchain_core.messages import AIMessage, HumanMessage -from deerflow.agents.middlewares.skill_usage import SKILL_USAGE_KEY, build_skill_usage, record_skill_usage +from deerflow.agents.middlewares.skill_usage import SKILL_USAGE_KEY, SKILL_USAGES_KEY, build_skill_usage, record_skill_usage from deerflow.runtime.events.catalog import ( MIDDLEWARE_SKILL_ACTIVATION_TAG, MIDDLEWARE_SKILL_SECRETS_TAG, @@ -28,8 +29,8 @@ from deerflow.runtime.secret_context import ( _SLASH_SKILL_ACTIVATION_RUN_KEY, ACTIVE_SECRETS_CONTEXT_KEY, extract_request_secrets, - read_slash_skill_source_path, - write_slash_skill_source_path, + read_slash_skill_source_paths, + write_slash_skill_source_paths, ) from deerflow.skills.slash import parse_slash_skill_reference, resolve_slash_skill from deerflow.skills.storage import get_or_new_skill_storage, get_or_new_user_skill_storage @@ -83,6 +84,7 @@ class _Activation: remaining_text: str editable: bool required_secrets: tuple[SecretRequirement, ...] = () + additional_activations: tuple[_Activation, ...] = () @dataclass(frozen=True, slots=True) @@ -187,9 +189,17 @@ class SkillActivationMiddleware(AgentMiddleware): for index in range(len(messages) - 1, -1, -1): if _is_user_activation_target(messages[index]): content = get_original_user_content_text(messages[index].content, messages[index].additional_kwargs) - reference = parse_slash_skill_reference(content) - if reference is not None: - names.append(reference.name) + explicit_names = messages[index].additional_kwargs.get("skill_references") + if explicit_names is not None and explicit_names != []: + if isinstance(explicit_names, list) and 1 <= len(explicit_names) <= 16: + for name in explicit_names: + reference = parse_slash_skill_reference(f"/{name}") if isinstance(name, str) else None + if reference is not None and reference.name == name: + names.append(name) + else: + reference = parse_slash_skill_reference(content) + if reference is not None: + names.append(reference.name) break state = getattr(request, "state", None) or {} try: @@ -236,6 +246,7 @@ class SkillActivationMiddleware(AgentMiddleware): # None means "any enabled, runtime-allowed skill may be activated"; # a concrete list narrows that to a fixed set. "available_skills": sorted(self._available_skills) if self._available_skills is not None else None, + "max_explicit_skill_references": 16, } def _storage(self) -> SkillStorage: @@ -319,9 +330,10 @@ class SkillActivationMiddleware(AgentMiddleware): ) @staticmethod - def _build_activation_reminder(activation: _Activation) -> str: + def _build_activation_reminder(activation: _Activation, *, include_user_request: bool = True) -> str: user_request = activation.remaining_text or ("No additional task text was provided after the slash skill command. Ask the user what they want to do with this skill if the next step is unclear.") escaped_user_request = html.escape(user_request, quote=False) + request_block = f"Treat the task text as:\n\n{escaped_user_request}\n\n" if include_user_request else "" escaped_skill_content = html.escape(activation.skill_content, quote=False) escaped_skill_name = html.escape(activation.skill_name, quote=True) escaped_category = html.escape(activation.category, quote=True) @@ -330,11 +342,7 @@ class SkillActivationMiddleware(AgentMiddleware): editable_str = "true" if activation.editable else "false" return f""" The user explicitly activated the `{escaped_skill_name}` skill for this turn. -Treat the task text as: - -{escaped_user_request} - - +{request_block} Follow this skill before choosing a general workflow. Load supporting resources from the same skill directory only when needed. @@ -372,6 +380,9 @@ Follow this skill before choosing a general workflow. Load supporting resources if target.id: return target.id content = get_original_user_content_text(target.content, target.additional_kwargs) + references = target.additional_kwargs.get("skill_references") + if references is not None: + content += "\n" + json.dumps(references, sort_keys=True, default=str) return "sha256:" + hashlib.sha256(content.encode("utf-8")).hexdigest() @staticmethod @@ -420,7 +431,29 @@ Follow this skill before choosing a general workflow. Load supporting resources return None content = get_original_user_content_text(target.content, target.additional_kwargs) - resolution = self._resolve_activation(content, activation_decisions=activation_decisions) + names = target.additional_kwargs.get("skill_references") + if names is not None and names != []: + if not isinstance(names, list) or not 1 <= len(names) <= 16: + resolution = _ActivationResolution(failure_message="Select between 1 and 16 skills.") + else: + activations: list[_Activation] = [] + resolution = None + for name in names: + reference = parse_slash_skill_reference(f"/{name}") if isinstance(name, str) else None + if reference is None or reference.name != name: + resolution = _ActivationResolution(failure_message="Invalid skill reference.") + break + if any(item.skill_name == name for item in activations): + continue + resolved = self._resolve_activation(f"/{name} {content}", activation_decisions=activation_decisions) + if resolved is None or resolved.failure_message or resolved.activation is None: + resolution = resolved or _ActivationResolution(failure_message="Invalid skill reference.") + break + activations.append(resolved.activation) + if resolution is None and activations: + resolution = _ActivationResolution(activation=replace(activations[0], additional_activations=tuple(activations[1:]))) + else: + resolution = self._resolve_activation(content, activation_decisions=activation_decisions) if resolution is None: return None return target_index, target, resolution, run_key @@ -469,7 +502,8 @@ Follow this skill before choosing a general workflow. Load supporting resources activation.container_file_path, activation.content_hash, ) - self._record_activation(request, activation, hook=hook) + for item in (activation, *activation.additional_activations): + self._record_activation(request, item, hook=hook) # Mark this slash message as activated for the run so the tool loop's later # model calls skip the redundant re-activation (#3861: one activation call, # many follow-up model calls). A new user slash message keys differently and @@ -481,7 +515,7 @@ Follow this skill before choosing a general workflow. Load supporting resources # (computed once there, threaded through here) rather than recomputed. if run_context is not None: run_context[_SLASH_SKILL_ACTIVATION_RUN_KEY] = run_key - activation_msg = self._make_activation_message(target, self._build_activation_reminder(activation)) + activation_msg = self._make_activation_message(target, "\n\n".join(self._build_activation_reminder(item, include_user_request=index == 0) for index, item in enumerate((activation, *activation.additional_activations)))) messages = list(request.messages) messages.insert(target_index, activation_msg) return request.override(messages=messages), activation @@ -500,7 +534,8 @@ Follow this skill before choosing a general workflow. Load supporting resources effective = prepared if prepared is not None else request self._resolve_secret_bindings(effective, activation, hook=hook, activation_decisions=activation_decisions, entry_registry=entry_registry) if activation is not None: - record_skill_usage(getattr(request, "runtime", None), self._usage_snapshot(activation)) + for item in (activation, *activation.additional_activations): + record_skill_usage(getattr(request, "runtime", None), self._usage_snapshot(item)) return effective, activation @staticmethod @@ -525,7 +560,11 @@ Follow this skill before choosing a general workflow. Load supporting resources messages = [response] if isinstance(response, AIMessage) else getattr(response, "result", []) for message in messages: if isinstance(message, AIMessage): - message.additional_kwargs = {**message.additional_kwargs, SKILL_USAGE_KEY: usage} + if activation.additional_activations: + usages = [snapshot for item in (activation, *activation.additional_activations) if (snapshot := SkillActivationMiddleware._usage_snapshot(item)) is not None] + message.additional_kwargs = {**message.additional_kwargs, SKILL_USAGES_KEY: usages} + else: + message.additional_kwargs = {**message.additional_kwargs, SKILL_USAGE_KEY: usage} break return response @@ -573,9 +612,9 @@ Follow this skill before choosing a general workflow. Load supporting resources # consumers authenticate the source and resolve the live registry skill # by path, so caller-mergeable context cannot forge an activation. if activation is not None: - write_slash_skill_source_path( + write_slash_skill_source_paths( context, - activation.container_file_path, + tuple(item.container_file_path for item in (activation, *activation.additional_activations)), owner_token=self._slash_source_owner_token, ) @@ -612,13 +651,14 @@ Follow this skill before choosing a general workflow. Load supporting resources # The two sources resolve independently — a transient failure of # the fresh slash lookup must not zero the entry sources (they # have their own snapshot and never consult that registry). - slash_path = read_slash_skill_source_path(context, owner_token=self._slash_source_owner_token) + slash_paths = read_slash_skill_source_paths(context, owner_token=self._slash_source_owner_token) if slash_registry is not None: # Slash source: exempt from the ``secrets-autonomous`` opt-out # (explicit ceremony), but still enabled + allowlist checked. - slash_skill = self._resolve_registry_skill(slash_registry, slash_path, require_autonomous=False) - if slash_skill is not None: - sources.append((slash_skill.name, tuple(slash_skill.required_secrets))) + for slash_path in slash_paths: + slash_skill = self._resolve_registry_skill(slash_registry, slash_path, require_autonomous=False) + if slash_skill is not None: + sources.append((slash_skill.name, tuple(slash_skill.required_secrets))) # Entry sources resolve independently. The same-skill exclusion is # anchored on the AUTHENTICATED slash activation identity — not on # binding success, and not on the declared name alone: a @@ -631,10 +671,8 @@ Follow this skill before choosing a general workflow. Load supporting resources # first, then the entry snapshot) additionally cover the cross-path # same-name shadowing case. slash_bound = {name for name, _ in sources} - identity_paths: frozenset[str] = frozenset() - if isinstance(slash_path, str) and slash_path: - normalized_identity = posixpath.normpath(slash_path) - identity_paths = frozenset({normalized_identity}) + identity_paths = frozenset(posixpath.normpath(path) for path in slash_paths) + for normalized_identity in identity_paths: for registry in (slash_registry, entry_registry_effective): identity = registry.get(normalized_identity) if isinstance(registry, dict) else None if identity is not None: diff --git a/backend/packages/harness/deerflow/agents/middlewares/skill_tool_policy_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/skill_tool_policy_middleware.py index 3d9c0f65f1..09ebb766c5 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/skill_tool_policy_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/skill_tool_policy_middleware.py @@ -17,7 +17,7 @@ from langchain_core.messages import ToolMessage from langgraph.prebuilt.tool_node import ToolCallRequest from langgraph.types import Command -from deerflow.runtime.secret_context import SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY, read_slash_skill_source_path +from deerflow.runtime.secret_context import SKILL_TOOL_POLICY_DECISION_CONTEXT_KEY, read_slash_skill_source_paths from deerflow.skills.storage import get_or_new_skill_storage, get_or_new_user_skill_storage from deerflow.skills.tool_policy import ALWAYS_AVAILABLE_BUILTIN_TOOL_NAMES, allowed_tool_names_for_skills from deerflow.skills.types import Skill @@ -177,9 +177,9 @@ class SkillToolPolicyMiddleware(AgentMiddleware[AgentState]): def _active_policy(self, request: ModelRequest | ToolCallRequest) -> _PolicySignature: context = getattr(getattr(request, "runtime", None), "context", None) - slash_path = read_slash_skill_source_path(context, owner_token=self._slash_source_owner_token) - if slash_path is not None: - return _POLICY_SOURCE_SLASH, (slash_path,) + slash_paths = read_slash_skill_source_paths(context, owner_token=self._slash_source_owner_token) + if slash_paths: + return _POLICY_SOURCE_SLASH, slash_paths paths: list[str] = [] for entry in self._skill_context_entries(request): diff --git a/backend/packages/harness/deerflow/runtime/secret_context.py b/backend/packages/harness/deerflow/runtime/secret_context.py index 0fd5cf1e14..32e82ec79d 100644 --- a/backend/packages/harness/deerflow/runtime/secret_context.py +++ b/backend/packages/harness/deerflow/runtime/secret_context.py @@ -103,6 +103,28 @@ def read_slash_skill_source_path(context: Any, *, owner_token: str) -> str | Non return path if isinstance(path, str) and path else None +def write_slash_skill_source_paths(context: Any, paths: tuple[str, ...], *, owner_token: str) -> None: + """Bind all explicitly selected skills with the same private owner token.""" + if paths: + write_slash_skill_source_path(context, paths[0], owner_token=owner_token) + if read_slash_skill_source_path(context, owner_token=owner_token) == paths[0]: + context[_SLASH_SECRET_SOURCE_KEY]["paths"] = list(dict.fromkeys(paths)) + + +def read_slash_skill_source_paths(context: Any, *, owner_token: str) -> tuple[str, ...]: + """Authenticate before accepting a list; retain the legacy single-path shape.""" + first = read_slash_skill_source_path(context, owner_token=owner_token) + if first is None: + return () + source = context[_SLASH_SECRET_SOURCE_KEY] + if "paths" not in source: + return (first,) + paths = source["paths"] + if not isinstance(paths, list) or not 1 <= len(paths) <= 16 or paths[0] != first or any(not isinstance(path, str) or not path for path in paths): + return () + return tuple(dict.fromkeys(paths)) + + # Reserved run-context key holding the per-model-step ``skill:activate`` # decisions for persisted ``skill_context`` entry paths, keyed by normalized # container SKILL.md path. Published by the skill-activation middleware (async diff --git a/backend/tests/test_inline_skill_references.py b/backend/tests/test_inline_skill_references.py new file mode 100644 index 0000000000..00dac3a799 --- /dev/null +++ b/backend/tests/test_inline_skill_references.py @@ -0,0 +1,234 @@ +"""Explicit multi-skill activation uses the existing owner and policy boundaries.""" + +import asyncio +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from langchain.agents.middleware.types import ModelRequest +from langchain_core.messages import AIMessage, HumanMessage + +from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware +from deerflow.runtime.secret_context import ( + _SLASH_SECRET_SOURCE_KEY, + _SLASH_SKILL_ACTIVATION_RUN_KEY, + ACTIVE_SECRETS_CONTEXT_KEY, + read_slash_skill_source_path, + read_slash_skill_source_paths, + write_slash_skill_source_path, + write_slash_skill_source_paths, +) +from deerflow.skills.types import SecretRequirement, Skill, SkillCategory + +OWNER = "test-chain-owner" + + +@pytest.fixture +def catalog(tmp_path, monkeypatch): + skills = [] + for index in range(16): + name = f"skill-{index}" + directory = tmp_path / name + directory.mkdir() + skill_file = directory / "SKILL.md" + skill_file.write_text(f"# {name}\nDo work.", encoding="utf-8") + skills.append( + Skill(name=name, description=name, license="MIT", skill_dir=directory, skill_file=skill_file, relative_path=Path(name), category=SkillCategory.CUSTOM, enabled=True, required_secrets=(SecretRequirement(name=f"KEY_{index}"),)) + ) + storage = SimpleNamespace(load_skills=lambda **_: skills, get_container_root=lambda: "/mnt/skills", get_skills_root_path=lambda: tmp_path, validate_skill_file_path=lambda path: path.resolve()) + factory = Mock(return_value=storage) + monkeypatch.setattr("deerflow.agents.middlewares.skill_activation_middleware.get_or_new_user_skill_storage", factory) + middleware = SkillActivationMiddleware(user_id="owner-a", slash_source_owner_token=OWNER) + return middleware, skills, factory + + +def request(text, metadata, context): + message = HumanMessage(content=text, additional_kwargs=metadata) + return ModelRequest(model=object(), messages=[message], state={"messages": [message]}, runtime=SimpleNamespace(context=context)) + + +@pytest.mark.parametrize("count", [1, 2, 16]) +@pytest.mark.parametrize("async_call", [False, True]) +def test_inline_batch_activates_once_with_owner_scoped_secrets_and_usage(catalog, count, async_call): + middleware, skills, factory = catalog + names = [skill.name for skill in skills[:count]] + journal = SimpleNamespace(record_middleware=Mock(), record_skill_usage=Mock()) + context = {"__run_journal": journal, "secrets": {f"KEY_{i}": f"value-{i}" for i in range(16)}} + original = request("Compare ", {"skill_references": names}, context) + seen = [] + + def handler(value): + seen.append(value) + return AIMessage(content="done") + + async def ahandler(value): + return handler(value) + + def invoke(): + return asyncio.run(middleware.awrap_model_call(original, ahandler)) if async_call else middleware.wrap_model_call(original, handler) + + response = invoke() + reminder = seen[0].messages[0] + assert isinstance(reminder, HumanMessage) + assert reminder.additional_kwargs["hide_from_ui"] is True + assert reminder.additional_kwargs["deerflow_producer_kind"] + assert "Do <safe> work." in reminder.content + assert reminder.content.count("Compare <inputs>") == 1 + assert original.state["messages"] == original.messages + expected_paths = tuple(skill.get_container_file_path() for skill in skills[:count]) + assert read_slash_skill_source_paths(context, owner_token=OWNER) == expected_paths + assert context[ACTIVE_SECRETS_CONTEXT_KEY] == {f"KEY_{i}": f"value-{i}" for i in range(count)} + assert all(call.args == ("owner-a",) for call in factory.call_args_list) + key = "skill_usage" if count == 1 else "skill_usages" + usages = [response.additional_kwargs[key]] if count == 1 else response.additional_kwargs[key] + assert [entry["name"] for entry in usages] == names + assert journal.record_skill_usage.call_count == count + assert context[_SLASH_SKILL_ACTIVATION_RUN_KEY] + invoke() + assert seen[1].messages == original.messages + assert journal.record_skill_usage.call_count == count + assert context[ACTIVE_SECRETS_CONTEXT_KEY] == {f"KEY_{i}": f"value-{i}" for i in range(count)} + + +def test_duplicate_references_activate_and_record_each_skill_once(catalog): + middleware, _, _ = catalog + captured = Mock(return_value=AIMessage(content="done")) + response = middleware.wrap_model_call(request("compare", {"skill_references": ["skill-0", "skill-1", "skill-0"]}, {}), captured) + assert [entry["name"] for entry in response.additional_kwargs["skill_usages"]] == ["skill-0", "skill-1"] + assert captured.call_args.args[0].messages[0].content.count('2z#c9ox2T+fK!{&5mu`>DacNspp+}zggd4&6;2HXXDkzrn3xzC7+6&%+Kd?0y}iAyt*xD<5roDh z_`j^gL2og`qYob&8;gpHs;sIa65&+%lS)ZRx$n?gRYh}7S`77{H82O9o}R|XqiS+- zQAtYjAeB{?Q&Q11Ha9od)|$~Xyny~@2J2Yf+&rzIpulEN3?dAxLvnDCWW`1<$!rk# z6S}j<3*j*?Fwnc;J}gks?kA!sX`5m^Xs3Bt*M$0AOW-`a+x&RZQch%+_X8ITC-v&; z>MJ9Yll{7EY^q7%1OxD?ImUh5|-^a6Y1;bw6 z9d+9B>Z9N>Z4x-Ydvp~@0$Ni1qF4U?JCSkx@Id{9gn&2}3lwgfn^OoBM?EJ#o(;M& ztm`ztQ+dbdu?Uh#Z#-kQV~Y40<{6ltjzer{XsyUfK0CGXh6-9AJodWpNbV;N8Znwt zFnlAAusD?s$THv12mV9`@JG2^u43V#UoY$-*}p-9~%&&2U| zdNKz5yyU2rGBGqP=+f0EyjU&y#e+;*Ee%@+3on;5+$%>(N4M-i30BoYqu%x-egHb4 z1*@#0hK+$iehbM`Yy&+)R!pnYgxF=5YDMl;l!(e`q(SeScmRN;q;M*m1Be1{PxmWj z^!aL8NiJ2)!lOZNM?nFuJ|;Y{@)^mA#lSCIQ$m807cn zxCIP#A}IgTMHN`DNKH>&SYED}-axgBL{4NU+l)L>oG}d6eb~i;JGlXR|IVd2uC2sF*vYHs7>pOG=%3dbNKBI57=`8*CCfc%r{1d%93^=R>kpqvia@8bT> zXL5jRJes3SViP;MuWG1hadx^UEUMGuL_)?EgW?*v(7-GrhV(c768djjdHOxVlCY(P z6wm3qPHD9RYjxS*yT8>^ui}S*^-jzCy1k>ocOEZqZn{A&XGdoS)+I};(wdqWVC2yn zBI2V?cJ`_H?k*ZA+tupp>!Y9pdT{#v!9rmpBWm?cExGLUe1V>q-z6l+Ll6laDGnW! zGDfo2*#51qGM`@e_2pNdTXo@YUzr*k4?_3rE9okZK?^GCD$0BLWNS}y!r6wNavxEy z!3^4u^Jo^OrPuW_%C6aT?_)0Nw4r0+oO{#I))|JHmB#OumUE-+X{42f*3})BX_=^~ zoL#$kdGD2kqpBui^I&tgo)S3duy5bhNDZk^U>R z$Z1`^K6ipMF)_BymUnqDC4s?q>x zF(JW2W-?TB16$uTwLu-}Xtn&9_V-r7lgqNDXM9f;_n@-L+IX~xJ=CiQ5F=fjVY#igo4f**T;MSt9Oug%vg3>dIIo zmEibYT2VzA_04kmd40rvBTJNWI*#fh`ccxjpcQv}Ef>GM{0-OFQ&)*>tgoD#n$TPB z4C{4?)p|oPOZZv1IHB+B;C0;IdgqUwP1u-ozx7Y``e>H;Zp=@h)Oi+nEZEpmllSs) zwi~8Ge*F1%Z@(<>M?kYKw8Z7{?!$XzP0uj5`zo~-#3xax$U&>)k&VyK&xt&TP-bU94hrcsb7~>$8V_VPxy- zU)MC4q>8{5hPCH6U zNVp&H(b4miR#pa$@xvv0E017*vD*G?d(6SuXy1rEOemR(!ef$e*l~-_xkjOs3z+jKn#cIOv9HVlu4CJlCY{Nd&LAp zh|l2%fsPT7mdeq9oujLoK)|=$4(#mQz)-g~lb3;-A3%rVGgVC~ghivC$IZ}KQBN;U z*7hDQ-0bU0;$f<;uBc{mxj&RuWffCV!4&cIRxk+4xx3!eitz&io!gscLu2sv2*2)5 zaigOV6-i|mTv{Rs^iP=$_Mf$ihRbThXs2Q}-?j~@sK!mReF9j(;hC%PxxcP!msD#8 zZ);-LaBV+rx3aBQh8E0Mqo5YX9bYHZ@!cKVvbj4Qvd3OZ3o1o_-5t_-J*JV>g@s!y z&ii^Iyh}vUlQY}569Y0d+niEO@NX?3k_8n66@Up%BlJ>g#>Kx*{>(XZN|C?`|ch1zPa&&PAgE#oR^GGBO9aCdFTE z4d%Nhb|dE-yZ|9bz?5IwY4$_jXFIao==Wixj+8ZAFTb7Y*Wef_%W7(nSeX<~h<|I@xF{W?$ENMeYQ$X;t&DdJ#wjJ$v^l`TC?GY7O$>@w zRK6)QGA3j7j#*p_9U~#|cG95Y;*ve`SV!(6Gag))T(tgXj~~GM0S1=jcztt&Ij`I0 zki-H$H{r95f^P!HXDcH^yA`r%Xl+g7V`*XnDIBuZ2IF3COJ!(lN@laRVwOsfo}Lyy zoSvRe56T$7u~AnZ9}y85ma9YGLRUmOQk-wJV^|NTlItAOR4<*;6z{yC_%xr>QtN_U zH0Tiin+wS00@NAXg}{Z5rFI}jn5d{yS5&aFHX4Nx|D^YOQh3(Y@qFOJnRhx}5Qxc4 zEeQ>6$%;=ee^*vH>>d&mvqPSTp^g?16#1;M`Thw?$o4KSZ7wu6cgfac6oCO??mIj3 ze>`S`m6Vl>@< zqn+b(${;z8>j4FUt}SMlb)0~Knc?wt>3$r$TjH3iKw&vBI$ttqgwNHqoHZSuP$GQs zJUu?${dLLZ^)^4#ko^IQLqsl7L7cIt&6)?p|J4E#vNcy*r4#>NN|)|P~*?ak!HaSJMaTXf6?tQ5=z zk=0;e+le+KfE9$KRp{WlUlLr^uwzl>W42j8EqkwCk1%miJDl%0?Ylw{Ybr%$d2fzA zzn)mPqB~#6dt!S7l^AcH?(o@M8lF+SJ@<-fMkuu|TwhWtr@r=7Kcn&)V|Z_K$LLHR zrtetAWnPxUlRYW~fi2!*@$fMzC7w?>e8qc zlSYlLm8_a7Nt)U!O6p2Vic0AzM<;VfG;pSb_#C)YJS$b!n?w;1zMo-WbXOq{71^GT z6gRB`Q}xr_?z_j|b#rjZFmfc9>UzPyrjn&C2~&9ly;e}+0W((#pv1geALG`$U5cvO z#VmsUYWZn9&G*FY>k)?a)^6M7b`xwm2yXC#kB=XXw_0z!m#+GG*B7=UXpfPxRP%XQ zF+D)WI`|h9I~^ud0aQ)4Wl}W$>I3wia)LfPJZ-KdCGov^$TqI9?CLYQNuCbY?(&ay zgwlVpTA|n((C*sWR#(R&K$Kj>d3-=Te~msNhbR&YYwMokNq+wBej@t4CX%ZwPl5XB zPhuuC?7j$wb`}TZ7}nvOtbrj{*-KSe!|OMILO> zyxw@vj8#supKp^YHazb4)@nTne9w1gVq^P7qx(Wh zIK!8!by(f5`9;>zVm;f{8R}|Xt~iHD3vE9ay>IiKTH}f|Ls?}|J|-)z{&fmVNWg9Z zxXr$`BhasS;j*4%q&B)U07)bzmj3H`^6y0Nmcep;nWbn3}e?xA&JBnYz}b zC4&?HhmKX45&ZW5K-mO{L;ar^T7hnROO<^GcM#bsEnQVq1i=)a0mxwGSf>2Y6N=49`55Xbjv$SlCEcA^`>~Eh(1JJ+w6a#vh6lwp02nX$7i60n< z_`HWlM`uT8lN!*%!4T>xF{VRV(vo3JG?dPd*Q_cX*G(uPs5QU3sn72)dl# zE(O3|HwUX7F5iVE;Ucf?ymkHLmDSai<&|Pn3{`Y5G0?ZSaaYKBetcDKxZN%Q*&@j| z`SFjp^0bvoLN@nSf+jdX0Vs#l{aw4pbhm5QMnd6iCU&L8?Pf2jBEt1btJR@Ob=qb{ ztR*|FTVFp!baabIzZgPoH@hC!OzjRTrH3#wbVIW#Mc=1!LII4u=ZBLnIL zY-~&Qu=={XY#u|{iL~{O_n2Uy?NsxfbrQ6}ID3mCa2=e6kMq=Se*plhA zHaRVYeQonJ9XT~!&9$8^hNEKq?rx1?(fZ^f#C-DuZnI8AtAeU37778Y{ks_)+~Tq} z95|$~=#q9H9+*c-A-6M_JrRoT?4S*|v*^0O$o4c~LC0_6v*)4LOmIsqB(KVH za=Php>IoT^a`MWmibj}o^d;}w;Gz&$DG&;Rfa>osAR-a~MI8>GU(id^DO30zo!rie z>U*C^yq|wXn`v8IO4mZKI*T$}S$FqpY`0pWeubrLzud^(_Qn+2-(NDLA*Bn7SyVrVO9XS}z=nEW!ib^s*R|7tW7qqOdl2(V!2WA8L z^R1j}Ak@QNS`Vew7VT?K9x)3`V~a6Uv9wYeV!`immFiv|;k6!UPUAWD!Fk;DQ~xWi z2BX0>+m609GD^HJosVh96YR9^kDKLf{|`hwPS|=JPr2;IGd5z)hS=Jryacl79cf|L z1jC4@UZX0lMp`N^D6Fvd7|UU~`?VZ6uy^lJgw~rs;8MIIK)_s{wv9lp&pXR!p&}MV*J&*ef8>rO^dx*G^y#Df8pWouA;1br)o7kNXDNur5mLmU0S*<9IH3@PmF;?PGC+d5Wf1}}DXa1m_K z^TcvhLvnogu6C^C;6NFTfB>`E{6+uCuydj8=&KnUpU&#=ygf2fi{Jj3f2Hp$Y)n#p z{2Z=mMzEuwhebOyFhp9KY57vlGCE43NERb#8r|yUymx55diwp%(!x?+W78w;rzx`5 z;3v-DC(W9R0D1@_wqNn>**Zjc_$V^E6p-OJwe}~{e8Ffhvj}boB+qk?)5MbN#isAg zobfpwEv1T2fvvbW1)rQ|@c#Z`-uno`LG$0HKA@PiBo@{F#6P%Wvx`-cy7qyMhWOld z`T&Ey>H;5qyxt!HVms0K&K~X_=;)YvYkQwr9G(QLvz?lzc)QzrPayMn>Dj5(yC|us)~1#)f$(evwmI1=TW!uGs$1_)4;s`pmQkBX6X|0?Yw%Qic^vcl z#{3<2&>@Hb^)=UXb2My3S=hM-bF|M{GF{zBV1F28HZvzBs`U>vw#+yfLmzwln{hf` zpgt6#y^EUEaSotZsd{y(ceGym$x@R};{a=6RLxkqF)cW)wbh&_AxUXr;Q$lj%{>)o zv6yTSviYL$)`1!53{4>`BQE%1MpYkeDX^oD6V|$e+V%0Ndb0;sU(!m+!x0>9YGd=b zfQGb3XVjek*I>F!5Ky6?HbnN@ps>@61<{9}w=~y&&(^Kw(7idh^(7&O2U-_EJ&br! zu;u=Qh8LWd4Pz_fai6Dt?8g2$Ki%E#@PJdJAIIZ~v~4qm9jDcEhB}y+<@q6r-3Vp*v>q&pg3>J2 zKR?GPC$?!aGn8w?dFp>+G*(!*eimB%Bk*V>-fE%nfd<_v_%3XmoZa>p@7(b?{MkZF9Y$YfSrlsgaS<^9j?@jLYmV zQ<h1JSp6!+-n04VW}t}F_*I}PiILjd0GAxJ6Ct5vT#vwtGckUvV;su0xlQV z%F5bmek;>Q&(A2E%Bl(a0w*m=J--|W6*+!FLb{@uW@e-ngX0{7-e3Lw)mq_$9pA7# zm9N2u1_uqV)&5$s+=klwD+s)@BxCC)C9U;fhjNG+D`UXwYHRj)|M}jc3qPFC{qhn# z26ekO8H!EXal}gBlY<-Hvpfv95o0SVERyS1nu*>EzVg?ZY%3=r0gmg(;NdbH&+gmD z#|JDdtkM_^3%A?xv8k>X76t~)%igd2DuPs*ZO(06GfH;4=?Ds?MxM`;`Td{#$M}F# zJ=w^A7uaG@@iDzOc@{(LHKmF}FvHX+)}-nzpZkO=6sao}`?@1P3Kb#UvTS zi5q!4wmM!GoV*C(BO;Z<)@T43gv0-@AxZtIv2-D@v5|$1jafla(a_}8mM0^fvbwIi znwPQT!9P&+Cv7dP{!xB=AC9!-^gf#sF@S#iyFYy}2y{9OlU=d>Rrc7bsY_2FoF{QP zyBK1HburXD$!cSZR8>{f4opXju4XwE4JJ>yVtD1&sQap+}ewtqd}y&D$6 z=EKz%TZqSwFkBgV($^iQxueCwM2Gc2Z}KkGyUKk_N=!D;_Mx^ zHBHhHC6yX>b7c1H4+^JVnF6-{8)s()%Mz>-0h_xtZdA_%nG1dEe|U^HCQb3}|2*e` zx-`#OLt-OA-*rj6rIiQ6@z12Ft=pn}?{9+>4o}p11*)<#wYApQwbywRQTHCNR&4Z5 zER+%fj#j!#SNbl|Z9et^t7=VNjl1VQysKlCUyaPr9Wu*k3G)3NI|e=oxM(g#-F9dU zS3M&!^Nr!qfmAefe7d1+S?tc36kktE6%k~sXQz7M^dElK0pkJ;^m}@J7a<&nj(1JP zMHEB6m-#YM61@~NqdPxDg$1Pb0-1VCgTUmygN29#Mj#Ehg^0c~yPvmr3Sxg-Kj_gy zlc;b%!zXevFZ$2qmR&337t;sNp`pgp=U7~`?#$(qrcxTDhY!8KsHWOwK`X3S9;T(K zb0cWv-V&u#4GeR!ut;;GcbR>1nr;W9g&H$1 z)?t}EHd@+)$dZfo#;c4gpI#+&G#%bCbE?~(!x?e7jbbYdFro(6C`95~rbq}XP`2eQ=&8amj78OuP z`m0^u!#3;a>{Se%bE!1xu!Z`Y@7?l%^dozx9rKYB!5u zQC;lB9iOEWMSaL5`E`Hfe5h6OEr0Tml8>NO*h(?3f8=cRy^FVG2NfZIq7ciPi6Jlf z39}8O#qhM~?VwhY8{!(y_9IQ9Tg&iD>BqsLHhCXyN^83C?+ry0Ownu(6K35l?;88k zXoOg|Xy3%SYCG>KZRiZb!UZ8U6JDUY8`y(y3MDby#TjUPzI6y+FBs5X@9!beXg8gv zwBDuaG4oKjG%V0N*g3Gqgi(U<3>{tIhpw)wrH7-7`N00CKT%Q9;2~ym!4bEk!Sv&p z1pA0>+FC)JJ}_JrqFAgy6k(UDg>$TOy74$<8L8tF1gkgNyqC-7k7AJuzu)0xfBQrS z6t1&H`M_25f+!75lJ+M8Xp{!=w60E11D@zD9Ji;~&EuhP9NLL6{lK04p&yvVJM75g zmAeJ{prQpNGUEGuhf-|0`}1-KWkzH6ho0=E%3t-SmxGB}i&&9|t=fL7vP?3$W~dJu z?R{pxSY|;UiQZ9ean0rY^CX4O&NV0`Yh*zKD_*~^(ddMdI%kI%a_V27gk)edj3JX1 zPbJ__^bN{W*4siY8rsgHC959p5V1#so5+9CMgIX5c7P1%7x4@QAxxt)20An_*!wC9WpeB=Gs1KsH`F(zpkIfFGSDBfPI>+x*m9iWMo_+v_uRZoU%plJ!cn?K4W6iHQA+?EwM`_~0jbcv0K?TBgF@8l|+J z`?^j#F=e+ySuOG~W>x#g?iV~Rc&z+jCrs3H>b!Iw8dm|;qgk|OL|JD1*I3Xc1w!KZ+2>BI5GvuYg zQVOa7yEqB3((}Cd*cQ}&f2p+DaK;O!t>;qtMU5?}x`#d;mh;@iT@hOA>N+FaPiO)Q z>#7nmCMQD-tZWYUX$M6L_-4-Ji9^<-?~i43HaBRf(|4P?Rn7A-<_t(dTAFnvxsLy% zI_`hgfKU*~RCKdY^YK!CokFc~_kwO7^QQ!e6^j(jpY`W8SCVKnUYkPSVY_5VsnJ0W zKxPpW7e5)ATuUlATIXKVFRPnx9 zRb&^#m!hPk<@I=g1KU4W?`k`stG2t5@67t+TtScfaeCsD61ufxy`Cb&m z=jQn~)k!)e!tI5mVd~~q8tO@9MMg?$yH?lk+pWVXkuQs5jIQtfNLXD~_Hg&W>$2-) zEsYSfRP3dLU@9)b>vn7=m)rfE(aR-jVX^v{`3OrTm8m}5H|FduLU$XV0%Q2 z@OnHz8XO$-T3cEoC8y7It+{+JX>Epyk*)cAx8bToRa3fBXKAaZ7M4d4Z7p+YFFrOo zy4L2cy4GavB!>D$W8>vza3m7}t+{Nlcdhz@74uB_TL|Uz;Q%XpcOxc)e&lZI0o&4& zs%*I|xpC;wjgQL${r|CKVj-^HuLR3h8dtolCIP+pFc47VJtF9?~< z;rYEEoiLYqh-?W^|FU0Jf5vLn+r0I0Z7C<$)9+|vK^@qcl@%D+-?-vND-Jr+JTfEW zqaL}Z+k1W(xX1`dg!9OnCF^wy6N|ZS-77-hPkciCwSK9d+<`lRGM9^gy4u=k1eg(! zB{!GjZag`7*<&6OqDXA6VV8VWAM#wY`RP~NZ0@N zeCrN9oKSih3~AzD{iRw>`G-A;UOy+K;+Jia>c`qGU|Ht+?N9ksqsn_%WA*jmqxga0`U4*Hp#P{Ki) z^ZEMrXak|3&|5-lrr;#>tqN1Nu!SV52(fSwbV|LZvUsPVprxgt2|VZ{E-GM(!uQ@I zTWCq~d?GyT-t2NaVQdippcbn)**M(fX%}SBcc1*SgqiqmX7BDU{4n{`sIk$RoFfKG zMMI+;&s}5BUAYvakIymmhe>WNOYR_ud;-DuV5!pIOPJp@HRUYUb3qbDMpo8rdYi<+ zK%QdAQ-~SN|F$#8$TT1$lUaD4AEVe*SA%##K|aEjZlrRy9gjJX@>6N7m?5@F?9ZRw zfM7{3*Hb?R{#KmDSxhiZUrraZ$e|wJZ>0WT~>j(e;a$Wn&RGX_jtTku^AP=rmUo`CqZ~A4Y}jg!{9H#mW!EL4Ia9R zn73{yby7dC7bK$!kBC^X4iXIs)khZLqoAS_wG0b01L`AJVGhY#Y5mw2=XfOxS&GJA z%>&aTS3!jP7!hRR;aMjN5EmakM#Yh3wjOi4M0K#Y*8o*sA?jGB+5)SIvo1RLdX>0L z767zje>q>J%G=;$XunBjh@ueeFh=f&fquYHyYt2UU5zhkNnu$g11+zBp`N*QDO9sc zw-kPFQ*S~HpOK*4Dv1D(OE;oBk%~dyN|Jt@D6KFZN;XNC>?=vq&xylA>CR_$UQApGRzc0&b(g7=Z7_(4dVLvt zcIb$r-M|QVK9BR?8rE7`e}r0euHw<~3BN~}&6wlNUl;$h0ae+jbmFJ)_v3_WkGpFs z$tt#8pYakuF9-PIjHOUgi9yX%Z-j%m3`C~Y{)vG+dO!m3&T*WTw19l26Hc_Y37c9)0d_Iu#K*- zMgcwlk(wk*m5Y*~({?y2A9|6U{iH@1dx`x^EBu!~+ z8XQ8!2BtxH-OTzPnZvkn=V7uX{Cah^2>Rzuon-?wyDNA4R(_&F29xJ&-D9+E*gq5= zXbZ$tkzu+w2tLA6Eh0;9ZBCbA$&@+)9zPf|BZufWIe+L`!b>3dt3-2D-S9}_fJvV5 z8`@bOfVTx&vsu*14!7z-3Z@Vtc)ZHt;DXMNwk(y9grtaGI`6=F0M67xb)(&s&Bmjm zfcS^Eo6uQ>ssWKgtm@^kkgSx9OfbyQC8euk{7jY{+;#KA`78#Zy`_82cKCvPDNh7y`J5(JF}a|cSQtR)zeFv-{GGoRKeDsv>fyivIl&h4s0mk zyH+XsjEBcRIipXy;nMN^ zA}DSddYM`GRw>geiq=|x%(~DbPO+OVG2efIN(_;&<*cM8z)I@gSG`MtND2E?*c)5{ zZY0yz2R)W0P7L4cw|N4MuF;!5qw?RFyo_?s}Y=q+Z=k&jYLHuA)#-&y0E%{YpM3 z!6o!`{X$WM74fJvV6NlS)1q1(F;Vct0iCxB&3}8N6z|B#b-iA*U)@<`$n6y19{|8K zWVrV{QG#tWD(bpv&1<%pQXn{a)$7hK!N$>I$k44BoL`m4`DE^boHaa#niRM5D;}z$ zc0827iioD3Iq)X05u#b8^*;g0S?899&^a>_jK||V4yx+=W8nYxdL5?=Agw(Xb+>liPQ##NSlBsX6 zJzSCRluOg#m9Q6FVaJ7XoRQZ4b>HMst558oRIz-q<_lc~b;x->3KBHnhcdgtZZ0Bx z&WDm72Q>ZM1-AVXJVCMa`etR#Fr_@R@(Oy(nvuW@qcixL4LW3IYU%plj(4w5enYgG zB}#abU(IO#U1@PPxteajP*D@~D&Ah*^fmN*WW z27v}n{%1)2zt5+*>c->X&b5pZ#aJbbG*#Axlk-TiO{lRvbg_ ztMx{@9gkmgcl}>FZDXLm)R8i6R1=d$2RIOAShi;;XRIB=>5Yn1Kvtkl)r*P7%XoWDdy0+b!z2h1Dr)(gbZh9MzdE0(*PD(1t6 zxSf?CR*|ZsyT5)^Wr>4{tBK=)sUGbgezb1eE-()CjjdYDZc!=NB5058A=Bx$vT|}d z+>hjm#n4vKxQSS&!NA2!sQw75^j28lp!ptI6vJw*hA~EO4?( zA|mn+78YK%9irfMe$7lOB2m&?nek@_{~C(IiRKz~&g&%|3)0sEF^K{K{(HmG6Jryo z#BKcEwS)oVfxaJ~M}4oa9uKd}F3vc!sbup6!5>$~6*G_?Pp8rIc^E9yXJ0r%lq&Ha z+ZOARR8*2Hr1S`&ym@_swMEF`;joInxS|aLZho+}8TP>-d##okdg99`r(L57slgsi zfx5j6i7=g1?Bw4aKq^?bPY)*yO}Sw%56U5)Sz81`={uoz_=(Fv!kNMmq%(A7?F` z&J71;b(Emq;*KR&zr)+x+YLXtD}I?6$VR6N3r=PoW;jZ!syF<7ls%7!xS*D|wN)hp zbb%xrU=|zUc;eq3;Qay=l1L3d^SOo`1I@S#nj~ic_F5fYU3pw#jhF0O>~tC>0AFH8 z?(?xEHG)EULpkdWH;;y0MO-I7r{%x54jTe99GIAgZU1#yj{OaY@*wDym2`}9qpHUi z&^_?HF%$v8bt*d9SqW#SyZP}%Rtgj14Cn$pahfGEUm=nG?EdEX;OSHvfzs$(eT4*e zOs}YvGt<*0`5ghe#H8LCP3Pp))Vp-ql@(Ri3yWn1#Ig_Nr5FJak@~H1{D#c?hoY$Q zg`i*~Ee&jKZ3TRN_tr5iP>QCC%I+^<{Le=BxQ9;fj_6uj*mutDz8^>5tpCKBF1SG{bRN(`n_>kf zRuuf#c?#lnKfgk)TT0F7l^q=Jmz%B>If?>^DauaN@b7LOQ6(V?Fl>a_qXv405m@Cn zOB1Q&tg5dYd#?h={6*9>J*Z%VHY*Yop`Ex8Dq(^LeYegk?p@9;Vct zx?8l?M=zjBB8(xyB!~btuq= z^xf%l1re>ncgbeI25F%Dy9(_>f;FhckW6#A-T~oTa1jz3dK&$gIzS}=e7Sni^~`~| zw5tg)9m}x~L&nesn;zVTCAWsnq7%>*wnu3OXHaFj7%uQvzcJ6;!SIDA~E1Jw}YG;Q-r~9QumrI0aBqM{&^)@00Zu#WmxNcM{(x)_7{LS zb)o>paNXzH|3oG_Vn+#-_7J)85fKqFFfgh#q3(EA4Y&>U;W~fZxxe?zKa+(N9GXR* zRox^8whl-BTAzf&Hge#5VW*xrvM**s#e_94nY?y-=Jp}xnlBblP{%HLrTW8l~4 z3;gJl=pQw%vf$AKH;Ur!S9hx;Qq(@;p*!5ws5FOx;u> z3(pHX!^k|E85XE&KajRnFMxvfolPuIT4@ct5x-RH>r^$g+|N;Zt?pGwM73C6X2>KL z0;*xi0NV?=q#_-D8GTbMjXPM8FgR%PXSavwpronPm5iQ+>0-V@6eQq6_`HP{%yUzH zJ`UOHS^tx_vi2Xr+v`7?|1od;e+B>2W8&lIfEmq^-J;?WG8omQ<_&Bpu}Y)R4P>ZP2RkLCrD>|=IaV`8exsUb68{#5z0~U2b3CHc z>BJdtMoWkXje4YK#*d-biO%_^aa~QM!ATzz%`h@k@Svq6rChBvrZmrjfS=7)-ZM(F zzMynuD}`<@EW%Kx4P;`)u1-;TIqY(3N=6M=noysWWg!F~r8&P_&t8-D8sJ@Ag**#X zhksRRwKvK0@@~Fkwcgw;1A#zY&xaRMp&&nf=l6lK%MH$Yk=t81*^SxZuSB}opXoyz zDZf`xj+_5+v~KET5)z?oNO=zvJKLw@~L%?AL z!XkqtBs$pyjs&TP$J;&NlBOH4H1;>k*49=aY(eiP$LGSd>ZGPNGHXZ%qL%1wCZLU4 zKr$&jOH{(t4-cd zgOVx6jQ`#Uy+N~Thy=L|Y&!5p!BXf*=g}Y)8cv77BIksk9rZ09bFM8&v2|^~_Q=B> zHQG`~{L2>dK*4llsLG_6l+vEDMhgg1RBs%|3354G)zl#-&EhX`Q*njr{!}FaGL)nx zyDd7jdfhE$ftb;X%E~_LEMDthwCa1x3rkBrw=d1?EZuV;O0|D+(KrOT>87Wb81$eo zh$3~?67%plyv4A*g@g6(;1LDlVFI>G;=iB>egAda(yjkYL^cMngIJ)gJQ{e`s59lS z$|HZ|5Cm+u!}H*#S!*`{Hn0l%Zat}DDX+S+lG@YhiqS~UH_85wp_3gx4IdMgXa7)D zYFOS4JRv8)VbKY6eHFvzyY@bQA5Q-@f~1Bm)^A97`hS?R-qr#V7aHRFp+rSOdTE7)>oaDH zXC-1`hU}OJgneu5?47fj^`lZ!xMV`bV?BB@#<*Von+w>31e3I!5dCl>4nb;aY!nd8 zq_;u$pnwhU6SUCZ=GU5-n5ZShSJ1RMo+bySr?=-iVrgmVuhedKHo9)hl;|00H8pn3 zDl#%Q+kX)(y}jQZO=KM{4_=q^jX6yMNI<=>W}l4MSWqWWp_B~7HsMniaFw>Y`Ad0D zm|NeJ&pH#4gS|e4wB#a8WYn;zK%P#H&D}=O;|~q)+A$*;g~g=M>IzfMc@tSKS;NA> z;D7|3=H@S;sv{+_{L|w*PMi5Sq9v|nB67apV_If@*jgD4{Nw-Ne+j`XG&~@EyTgW5 zf*d|y*SyST0+FOdv-+#Ik zb}JHm9-pzHp>$C?5z{wE0cX%)V1s~g4oK(3Iaor5i0E?cX26r5bXQsv`YnOVLERYcj71Cb`EyBk;Kyi6qOD(7h2 zFqpZ7S;I-gg9eGkiAx=j+vA5PeM2CeJ6Ovf%s4D~FYk~1gV44nF_F0edjl&V;WpP& zt^y>-e_U^kG;lIR0h}Z8&HVlAA_ zH?(CuJ~25xP>C}gi?zHQBl;*RN{Y#GaC+C<+xGn(RfUbvNFgZB zWqc{iiw}oc#n;V@S1LuwUygap0e1q8pH2&;x~`o$E`^rDzK1vLEBLy8`MFh4g_y)r-J5%ehtE*bI9OqW zivbh)jY<7}?$Ns7S<}10-z9(p1wNEYbuX3xVQ;n3EfEbXSp5!V$C(<$NGb}&x1-pC z*AtkM4L86e$glq-@nQ_-Ci->qXVMHl5NQ5WQOpRM54o*}nC;k1nBcN%o4iLYKpgv2 zPB=bygfl;|qS?!g0m;#)z~oo|0ttm6z1Kp&)s{)e3)cXMe+4Bki=yYH-E=IZN@GjQ z@qX{^CVi*Bjr9j_i(oLzfBxcT6?fPVLV!-H^6BxW!eXc(9wFA2jHIqx5mXCoyr zpN7EJ8_ccTt{&g}S6^K}x}T(nlx`$hFK3+6NmVn+fr&j$2I$hT*?0b{Vh9y!RX5Hdom@ zi9)H`zK~})B+ReqHT*Bcy=7EYVc4#VbayPe8|jh`>F$mN(jnb~bhk)Iqcqap-O@;> zq_iL*!g>ATjPKVzdyg~DIR14ln9O+R``picUr+dQ*CcovquSXNGVF?6`(!LmS$@z3i5BC3lOEwK=O zt{vteRzn>O#E*+PiKw0-Z)K23+@*IVkU~R0MmotF4SyJ(no1XN@X^1oE#>9W;t?FV zOdM_qO$`ToF^rY#AT!^mTi;kt+TYjL?^{2sk@G|5vx{i=8=8C;GE95P-V0znZ~fiI z`tEXDC3KkCCAN{FCW&UwC?qJ9|F)lpfT6=f;x^n^O_9*75_$L|WK1_RY#^vV8}wCu z{n{MRLMKlL;E)nkKNV)=_ww~~$QiMQKtsefs~i}Wuk*$7g2H`&q5%D$QK*HR2?kyE zZocI=y`A5RyitbJ8iUj2{y7b3Mr=9u$B^!m>-UX}Oc1VO_|b|mfCgJ8muE^*YJWnG zKbKG8oc%abh4^9rA)TcxmfYl5{Z_=THUYCB1$yEDP1~r$2A2?)?izJ;pS5nYw=JpW zs{P)`p$J8+m5ENmUgDe%Lcd*2t;8R$`ug50eXNoVdXEmhht&-CRIsrb zJ~=TC&$M)i{Z{Y2DJhsSkp4Q_yt1#39G>JV3^h19jQLsG-MS`%1g;!^(97Mu-)WGb zcf82d$H#PwrOyN1Gl4sfl( z12bY-Lw@Y-?e_<27)%fph4u%&mg$YJa`Kw6TNi?O@;z7616K3WYC=85sN@SmQU6ogp( z;9%E!%0L%}p%-b8o5InU>ZHKstA(|0nED?rLZ%384u&h>MVvWn z8kz#;gY^h}`yjz^tHw$J@=mS27rhc~?+#3rht<*9a9#0GVm)j{Gl~lguqc)&i*8Y= ze+$oUVb6?zO?dc>>os7RD01;PvAasYk&s;0o7^6a>no1ae=L9zEH8~4;_^tENjI#N z*?EUn_roG1X;Md`NJC&fhqR-*r!>7SA-px`Km#f`5*(j6N}V4}KnX6zB7cR56WoOk z$Ss@ioFu!DuFp?T_g*wg1s{lT=0tW`uk@$(2ZRzMcUt#VX}f-Ph{gQsc0Q1pR3k^3 z)#$iKV$AjyhJ-R?fzaB>sN){ApD?#0wh#NhjJNoT(6JW(gxGBO{vzO#427oXY~8AP zyXddMt%=<5vb-oEjXSIan!EW|fjjxn37WkF>fTrdrLwRAMwZu?#WUvQ$C&nC>SAU!zpt*;l#AYOY zCZ1!sx=}CgB%pS0uy=av?&w%=BlWIu!=sx*6FiLn9z|5|WkolKJ+HGz`p5ojI#F7@ zW=X3Tn%)_2Jg;$(<3+>D5VD;)3+uskn>oO7BL6bGY;TqIqV0976iKwtjQAfR6^M;{UF8sfQcfA;2!hqSr)1iiDn)Ta?+R5A_A~&Pe zM7)NPnBP`zH0mKs%F3kV3!ZH;3=SXRR^c#w;(1b9Se0JJwjl{VHK&F?umW--E zmke1aCLuUzN0A7vt8>3=YKqzd$;6e;1DGKEPb~)pHf_izysy4Bi;e7E0ZXQ#p`q<# zLFUM36w3$<^3o~y`vFlH1x+O>qbO%gtSOOz!+g;{m><*9yiaFqZ4$pKrB{6$opkqd z3o!r9`}Eoksxi}sVf58Zjhlxj7Wu6xbdBk$q1I3N5S*!sfqUl~&e-(IhNhooWtDkQXo@g_`B3T7IG1<#=L~O<(iKGH=hdV%u$8>kOZ)dg9M%=Lw zY!x!+7&9|6vPC?9Zxs}QHXi7omT26?e`>nCn=6vTytg?WlB39~+9etZds7f?6e|f1 z9)}{A*gX$yA!$XC$x`4BPg?-ik{x-aO=f-o0rhL{l`ru zJ+5AJjYO0x+ykS$K7awD6nAZFE4Bt2_%~J6Pt9kqlcl6!3qmb=7PU&I3=9mVD2L;T zd&A87fsmaYgmAyw{>=G2BZ3O!WYJe05F4YE@%wI0p7Zt=0ThXrvKT+7#q7bJU0%0D z6q$qCYDCeV>k#wqCAaU+5-4Xf%m;n^eXpF&G&RSH#y|=JpNJ^7APeZFP_DFeJ*L8+ z<%3}nPG86ljHdbkiAdyOnyu8x$LFvlFUWiEm`~C&$L*Rub0h>>FMbinZk1jH4??+wUD7xxm(aF07Bq*TJDYdh@zkwE1B1` zt&Ed27yDMWAXYv@Hqi#&f~uX7dLaKt4h5@8K&|2T|l zg$8!f4CoxFo?=nH60@3Z2@88ocQ#*^)08dKt@)k?(<93d_U3M`%#Ija5SYo3xm8l; z-Tqy#7diL9wD~IFx+S@`)}K|tDcRKn>G^9_Cw)hE88+V+*^FJ0+lgJg;l!b4msde| zqk7*xBlH(EAX=$GzXbTZG%^4Hqz_d|K}8(2iV~aCn*MID7=i2MR;6{$g6I4E8WNMMGrP9qGf1+9Q^S@-Q78vADbj#SOP+1ku18- zQ@QFEoC2=iuh{hRQfK zjaN+f2|0gqeIIa;{QYGc;B9vnE&jc60wjP-^Y`g(x1pgv6Vl^;D)|lbrw0lM$jm;e zgHR0+=U?hK;D1CDvk+ff0yY;Rzmp%sd4az`8i|&+&qvvoEfSiGz<3=jEqBcvmta7f zy^M|K(~`DpW8wPGm>j29Rr|Q3V;d_TA1_Cd{mOFXH3vr`#d@2_wS7jlJDd17l5HW8*atr@EAqPc$TaLyt4ryGY)y60ZVUHO@>5 zIxo)d0jQy$*4o*b@bH`x$2EQPQJRq?rEW*%{OTy((wAuW)?#^0yG9Xy zy$HLklAN{4^2Paibj8;sud#1=(g^lYp%${OY1WzGc^qr>;%Tb4y*4hJNrBIsNjnkB zj0rE3ep;4z{{A3b z3Gd2^3N0^77#Aq3BfqVwH4IP%Bdf~4;%8;>Csq65MIO07CZ+(DB>=D-tr@ zS16P5bZhNM^UO*6!bb~WhNNs`(n3^JNjUFj1=th__}Hqo&kkVPl~fr3)GfYGJD3TC zh(`(jeT&l-E=8Nel3>A2&xn>g85}Toaq<4YZ@rT)oPST3ro)qA#9w>^=vjqj=4Xh+ z!5JU;8qOBq)9K8!Sic9i3D2)pWC026fp>{Cj>*QD3NtyC!DbV>XyaaPveYyvAuTUk z;>=_J4}s^(0=zg#*zcmh;9;@!g3ZAm#f@!bHBS$UZ)>doFG-ZMFw#+SPT@LG&&I~+|; zPN!{NM<*vI6H$Lq;JSGg*wgJuHw_nSN_M5tVf_qmo!5mx=Y>`UNUWE5j9}mKW)kxa z!n6LbkOGI>Tv34g({Ng8h>oi)W!kLweyb8sf%S-ci~WPT&Ef9uj#DanZOu4Sd9lht z&&f7Sq@OJZKwQBY&_pkXi<`?_5cZWZId`?*n2ZbHG zSp*F{QaFT^)FdXlm*PZCSy@VrlZ#8{wyCyORZ&&MeV+}lgJHt!5g43hz`v{6bM&Q_fp?Q z&BEr2@$nH5gebcRp}8x?ky+^WwbXSN7UG&Uy17q{PHubd93N|%ntn`8?N8laikm=Z zlv^`pWFps7L#Jp_S+ZRyC!jrJM1M;!=>ce@UpnNLnNw3z47IhRx`rc{M@C#AXpCw( z6t65=N+N}2pFkv?SMi$F1BG|(_?QF@?N@fTNCsWN5`M1_W_!=zvS-ixz`g`8VXw0| zHTkH7grP&yMIknB?%CHqXpAQu5UZWw5&OfW-VsW~@wOXM&AHhR!vX8I0 zhfQn8!RzVC$-$V3-FHj%a^BA0dX8c(Dt=^s{21KlA}&x_Jf2<+A^jxy>eb5f@(|-d zduv;x)2i+sw$TfD{^xRINrNyKf>LE=Ul6*raQ^BuzWGHd-F&V9;-?y9&Pk&ds3GEx z58+R*s4#jCeCJETgm$KFYs;kiLJPAbbhKX$Nf>7rLk+PAtB;(FtnV$^Giqz^T4+oG zz00j{8z&|L;*&#JFB1q1OQ@*uIZ6|}WH|FZi%J6y(SV=bL3+w9wZ6^d9CJQ3)3x5+#p@w;Jy9?V3AK$VRL8!E?BzN`@!12_K3sKvCo+`JNq`H zXLPg-Lf7(Y=#|?wk$duFhcGxUW zBQ@48QOg@0`}QV6Lhf6j%eO!GeZ@U&%m09$VN>e7H=kTB zhnWtdTXC(OY6Q2qV-vd*cs)IDME$+Ko8Sue8JieuX=;UrhEiQK;kF;)?(2;8zJ@%o z>*AaUU$SOcD`VYNl~q<^Gkz;|4`hEzyE^(%^5x*0$$JV(=bEOdKbXQA0W~ld7zM9A zFD#*((Q*Y)bJJ=^UYuKk$X z^@{ar;zwBB$Z#<-%IGR6Jh%DO7Qzc4U4_jA6B9o|1gF>5;;KW|#f01VmyIsvI zpr1f2YgNNkzX@|{*(Nax`xAQkNB)jE=pZM{f4%P#*}V8etA`0J^l4ee87KMumP3&@ zY?N;Y?QsaZp-xZ(vUd#p>=e${d(lRzGs!$*`}0S0n3tEyI5u{4zOAf)YU$TXqrPB?PRAeR!#2b};z#}5|QG^kPH_Gq*g{0Pqg#7txp@e&jg7}e6u)Z=Sh zX?WT20{)BK?b#Y~_#c#F)PcS0%(-cmsPA7sIoz|DbL;DCM}_lG%=rC2nW?v#N(Ea? zSvq>gozGojfgQP-7QWPlAVuW6pYu&R7wkm<4GaGn^|ywC9ST0)vx^P2ss0~4#uNR3 zurrA<5F>Z4@|A%#=Ab=OBJp|QEoJ&nG0+KVO321Wz*^n>`1|5wuc>(i3?edRLt5_# zq@2$hiIT_B3E7SB1}Uq4Bx2ga6NFeUREMD8$?hS%u=;(nj#h7Rl2Lp7idFz$dR^p4 z;%iCXG(D+Cf>HVR%qHPOxW{+psuJ&rUkD30ul)FNxYiwHc2n)ri9wiXYk-gcBX^Sb z#+?hzYk_ZLt&#FWeg#A^F~i)n>^v;F?+jhYZSi+nQ!ZNw>dZ&kA_`Xyf{r7c?QN1Y zRU?Jz1yZ&+eY!MJAi-SA z{r^87FaB4xlV&RGYMJPmm}r>kfwCboammin(Gj5a0k|9rb#jJACsrDOzdPzr`pQaH z%vDPH7%CbX8er_RDv=@sNef5o@Pjc!MRiqGRXN*B_Obegx~8UD0O4l}IamE}Ex^Vb z0`!U&HRhMhm6d>*(&Xjp$;Z#Fc~95}NdBmmm6ZS;Wo2ar4Qxtltjg)cYfnw$h^9Id zK0dZbyQ+J0o5QtmbUA4e9ul4ZXn41AM-L@A3Aoy;vC z_n*Z5lW+g#4BX;yy2PNmJ2^RVkz1<3{)3ylMLN>|JD@}+=CNUX^ly${q+2d;LMoTY z--CGrXGB|5{9Igib?-i-b4%)uBoSw|x9)sQO6m~^2@M7GDR11$Tg!JC?Dp1Fj!sTj zzka=}azy|0re{5wE@~zrryUpfYpg%`D3!8wB)T<_PhPN$DRHjae-Jkk2q4B>77}dZ z?sp}kL$PSE99FfR2Got-Q`!@a$Mssgpz{V6NesNNj2qGQWFkJuL4U4~CeP5r!%=CM zM-?u$)ve22;(cWB8?HS+6wYxl2KYCVbm+`WlRcL04I5_hZ-hEb+tl(Tu< zZNio0jX6F>6?=1x=*N4T*bT(m3oO#Rr7h|a!c3n2nKj+n=>q8%3d!h?Y$m2UI^j-A zN=l9{Wqo0oV>b;2%$>62;2JoUW2?-C6^=jZ4lrzq_ zp|~X{zxk0fict(prfHEW1s2iwRA+6Sh^OIM@!tc4gK%UR{NONarF;Y2EUi?~@z0-e z@hTp4*f)ox!<0MzHNpRW%YXhQ0VxCi%mT_5mX=ULm0aGL*D$0@nOPqh7&y(-br)$N zikt~|KW5ZRB%1ur9NE+h$%Vb8LNBl^e$c@fp_oCjx(XG@3K${h_ve2a3kv1PG}1iC zq8wYfpiF0%@-UoHA8w*hK4wWJer05NJdDU_q4s`bWnrI^o(`UA^N5J?=}9M>Lm;$; za=&qTrVf3mIFDpGUF%Hbqn)&&XZ|EkO-+44S@mS2(h0*~6XkQd!sqoX7)ILHx8-}b za7soD1+nuW1~KqT9*tLb_li44m9(pv<3ZnH{KmnXpjmp&#NV88fn|1y1dCiKIo2Nx`qp-V&ds@4QYKq|~ zb54}k7(z=QwY0Qqrz0@*^znMT)po$- zkT$dweW=QoSwd9`v9?oqipv$n?Np9$S~6HQ%@P)8}bwBSufGU z5+#}eE2^hwV`tHeVs)BHAy+&ccqYj`o91auR1^tpZ)EwD=-Qpgx(OF;ZSDE-AAV0K zx-obHIN{8nuPZP$X+m1&^jDWyckYc0h*==uag|R=jSXf*^1g4S!=(+er2tm0s!FHv zv9Z$=$4qJo-I3|7YK_9u6nNKebDQ%pE9c7O+Ev6%stVyt*#BH>YrH*r9nr+>{_nL` zLR=vQOoKUlp{z1Vma_)VY?dSI_P?npNjgQC^IEbbNpyJIC4O$Sx_UMvlwc@{YcnTL z^Lw>6(79@8KxeoAi}l)qECkL~kQ*BR0+xoTLYsCGrYdE z#}USdnhH?N;8Nb$TfDKf%QjKIZHL25VkL zm8(Ky^4ds-$Qn>NgU4drtUv#k3adIZHU#162|?A>fR5yEf}g_3r2H9mu!4!20fWI= zFg!jy`BSH>TY-j(69%y&_&REaJol^6_Y$&H3>d}V$F25$mg@VAmvK@{M^i<~zQ9A7 zeeohV?l9GqL!qvj_sW%jlF52+;f9`m@ZU%uEOQ#21(N?#k1=Pwp&SDX1B+unG(UEz z>Y6ElB_12&ML}t0S;8nZDFIvW-R4?RHC^u+sYW8U(gj6*5SIn zn0fDUNcp)8CsBOah(yjW1N?N8ae?5wWELFr-pB* zhG|(xsV23o^Y5WRRTm0kzWXn5D(Rm|5mG_JWpCN)#@DLuiY3!{qclSuUb4Y4Uqqdf zRLN4$$kN*gk2JP8b|xPEtyYSlapdvw-WQ##F(F?a5Ztn%Qw3a2*!AG*p;={|=aFx- zQO{kQRMnQ;adgp1=r{v^BMMB>hq-F-R%*gQS0*sjH0%FpRMY<#ds)r;l(aMx4UJJ} zII`TVkKgDd6zvTG?m88**{pU9Y&p!-lv76ER4PE4IB!W20~3wLt;U;j5D>zZFIj{v ziEK&$k|AkJ!izMD*sZTms@>z*<;T+K@6*B%SU7zx1yu#npzYb~GQ1mw>`$MX-yP(3 zz4Lpxf4C4m_+~aSI!P(iBHr$VNhZQ=KcLRYD97-^x!WR*nVDJC?K-T!zP>25^P&0G zt5yIp09M6Gt&BeyDW6G4_Sm>-M7Rq*0ZGiz@S%2>@#pbzU%!-r77ou3N);6htp%Lz zsv8cS04W-L!XO!ilwT=0Lc!m@WpT-@`fKvky8&1`KvK90C(-?l1^4fl_w}ho(Y2!> zBj$U+!Nw-!_xJ`1<*F<(6>VQ$UbbJ${E+Q-W0+kQZKvz?`-8&n06JeTnpSRTWTKU| zwUL2A6F@d$cs@+LvXudyY%T}=PHXA&-1W{*QJ(rbwQLa{V936dPzEO1F8+WeDrMFL z3h}fLA1oZ-;1d$g)=CSfX^rf;1*|qIMS>z)+SJq5!Z@I>)jhr3p&W3D5wCxS(p);! z)-@<9O}V(cpRTlq78Sv1nzj>qZ}0470S(=0)pi0kjWEx}M3>{&uiMj^D!?gqb#{K* zrX3y|8wW*%|Lw_QdD^F@2^oIpjkCQYOnMnHQ9lov=kDALN2v2g^;-@Bfpjq4C^Rww z@;1ub#H{(&}n|%7Sh3dSnuZ0?&&Vfwo?Idjy9Q z08cGbL@eaRtJ@0iZCg}P>D&z9BV3Ke}Mpdqg9|oGE0iqEaLS!9zMRy z;l)h(@i9~yX=QaaN8}yiX0E7rL2;AyVbY6Ip~IHC?}!VYcZborqHR*J>M2W0K8H%_ z?Bp7_X(U@#63G$hB|TN4i(EhCEPL`RanRukpx4x{pH8=8c<&unFvbpvO~@?J3CG$D)^;{&KkRM zue8c(>Y^f=JB1lbUlhg!%E@t*pD>y3d9{A~ED|(Et*t97OFbc>d$u+Xs{ms;nu@9* z#b^_P5tSPN{9a55?7=88(Ml0QApE;)*?04~v=kZ3dyxM%9X&l?&!*lzev1KTUCs9C zUbyD{gOBB(hMW78S#Cgt&Mhu3j$rOs=SD*E52TJEsjuS};MrAe=z$S`v?GvL?Pjjool|g)7!lp3ngci&nYKSJ=D@i;c+c zeQVTIygA-X<78a!cPC2%T`8qfR?OLmH zuyGw|o1#{jMUN2l@ML9WB{d>NFPSS=R(~SwGVqKqc@fP$vgx9BEdx0 z6pU61#VA#?_mh?JMW|%N!n##TuQuo1k6sA=1ouk}eK!LkThNyTHga3ZTbMz5#)rxsukD_O;BmNLRN44f!I#-~FSM%a>PV&XU6WU3nI)Z^axA)7D#YD? z2LSC6_VKTxl9IsN^B(}Y=dZK-+(3vWBZD;nb^mw=HWp4yb!o{q9b_&-S_@oDo*4TY230S- zcj9WyK$H9GN;5<1!e)5T#M}EVfgG+A@H7H?Ym|?Cp_KXf`v;0Ad z>CsGH-rV@nGydMZ9GT}uDwS~`ARnE6vHoX!M~_H`ZGGkWQ0WJ&OudZ~!9rc}(#X4q zskEZXGDHCZPIcX8o>$#Gine3@qH%oh*-#%l{OPZMKb;8m#5rgaaG17fnigCj_9X=) z#qd$A8BeG=lvlXgp;C6_zvkqJGWH`RoUXJ9EvZ2Gx5h);fxSoj`+VhQ4L!K-L&??6 z&DkF%-%9Dub{luO5j@|yD?pZ7XM%qtCHBk|xm4x$>s{Pk93>9rJix&rNgu}OIJo-Q z+NuMimDaVd_chmB$%ttr{8dwm6*pw$i|{ixqpzHJ(>s430|u(|JucK_u#P#-P|(q% zS_25YGK&Kq^Iumy8APT?cuYHKYprEtf=x?UBj!4AM4}d77Ld-vJ+Lg6*Tjzk2ZILZ zo5z56&F4!m$<8VQk>@(TeT@Yz``*g;8_-6~kCj(?g6lm`?`p1Kq9Z*+QLa`zKV@)g z9IutZIfytrJ7-8e)qByh3@;94C2E#}sKVERA&~x><7dIWbv?RTAlxSe0hR|M#FXRn z^H|Gdo`|rG&Nd#Yp3<)34>*4Bp%`;aX*N2%XY-W3Qe1Xd+sYY~1(NQ0l<%9iTjTv~>QVox!MXm{WhURf*Y7z<+1b6=q{Vi!H`SB!XpZ4yPb; zd;YI6qF^B7e@_biKiq!0wu=jEChQp-85scyK(8c9app&}FgE-Q4XF5l2FL{}6aj%} z0m8x4M+>d|qm<8VvN}3CAS%OD82%;gOFV|6?;mk^dSt1wo|E@~HH=KOG&Bm36YZ?N zAa@M+SFS~H@`0jz%p#0CEj^tzM0%ArqGfb$KE^qrWz^H-PCyQU;Z)f& zVqs_M`Q2R(KVuumboBEwnRSrVy6->6kJj@X;YecBV8f5V4g?RM?v9Y+4eN0zXa>o! zoX}2u|7RUie^CeH%G7<2nn`+CrTAc=(7ZoH^nD}ta7`NC7v|6!UKpGbvEW-@le%q9Tgd`64TiJJr@yJk&QE zg9H3W@KPGA9laeI<)3e3J|&eI>pSq&Q{jQ?67jvd1bYj~QMIC9b;~72X5~}UW{eE> zz+afKknXmGAl}EARSao`qjHHX6l^x1wu`0MKG5-G(;ovTSOq`;&*)Sk1K15F;{ zFa=wF9{sOpr_}AkI%&Tf9n3RxRO>f8OS`#oKSRjKc&#UF9exV$qym@kTGzXh>gwt= zouXf0ZiLxpSEo^h=JNRK=7Uz9F^E>-a$xiYh-c?mi$Lx{t?0`F6bbMU?%H0~g;ScuqoK~AG|2|;wSSwqC zp&IqpoOb4xmVf)urVIs+qHBJB$AfyzxZ*y>OzUqP3KU z)=;R1p<&E5_+ND7i*BFG+52BaT$Hav0{{B`xx2e?RS z?S46^U`tC&J|!X|B2$o&nOQfqzZmR<6;c`UeKH8JAk%8Q3k9VaSNV_@>}-d_>E48d zgq3pGIngEBnVA`IY_IpXx1n%IzvV1toGJ}()T&KIT^2rm)CG+*3Fkkzlwk>5e$vv? z=E=Z{O%<8Os&{*~9*b2^#4(gD?9DFTO~u4iqn2l?s;b)2ghehCR{`Ghx4(Xo3VC3E zCFfty#b$gt?YXWMZ(Gx@qMTrrs(o|Nytp;WByTb6qIC* z&F%+h4GkXIg<2(;CT$+NugZai1VvL*le(xyf1W4K*AxW`ty0St3^xdP{rWD6Iqj_2|3UoZ@cZ?Qn1XL>Rz$$X8-l!s}k){-$LC1u)tI_Zw@DrV4^i&VzW z*Yf?kz}pS8PXAjl?s&_$5O>;5{n>&6^jBNRj4OSy=vb%2cZxMEUaA1& zx;L*9QINQV{I2x@*xxxiHkK#FX{}{>a?*1k3Tka#9836;nT2JaalO?YR9SR>91m~| z3@kDsc@{Uve73jKd_U?Pmr<=sP`)9EhLmof*h+u_M&)Y5tgc71dw>66T_4UkJ>J!3 z1R)>~Xto11U9dPCu@3`}U*S_(HX$%SwU2*LxAWuE?=TiIeKCvQ*l z*-{RW3(3%0D|77@m3U)RL6+NwKyu$Khy}z}CK0+CvqEuuN}oqD#|3p20|!ut+b_jwZ!~HxsF3hs#gBY>55FAuy^2a z!n*SG;@-HP#SOwRm@KfJu-g?y(C&Y0sx0GweV8!h^83Wsm|+E-CGQ}bKQzU%jF+IA;RHMTn5Ij!L%la?;n{- zF5;U$SM{v0lxR#=+DZ6{JsgUTU=NJE_q(pPg)FAirL#Vl%*2rj_)TWrd;*c(j?8Vb z0VMuzy;B}s;A)PFL>5ovX zZ%uQ%)Se#xae9ZdBfYDu75Vu!z0wS-WQiY^KyYp)vheCJFv#rhV+|Sc9x~WPE)h8*bOzY zxshoYQd;yU$hbr#Xw_3o$uh)Sst(&~Yin8PB|}FiCIWV%<$xlbISuBE;AIpJ59NP! zVT-^fi%mWsh{l7Y-f2L3%)L3G^6hSWEPnpl+NAnwwr@*c{CIB(_50Ts+}{|8-ks*= zA}f4JZ4|g*J`{KK2n!u7-E;+}JS5719sq(9rZy5p`%3h&C@-(iQEm_D6s%@ZXRsA| zs(4ujcTZ*1Wq>6~^UniBXmlAmSlPd;(*Y_5M#LprRfiuRzn~ztbXyE9S)>H_&JJ!8M3#x+`;I*f3#CcITWPcwr%%AC z%Sy1WKcZy?|Yy$e3WRh4WS_cl{3`%TkCyssc0%ECgZm9=hJ(-U+d zk3$10UrQ3(ak}WB6{Cfpdvsvt9F^J4F?yGSS0g@BZ#X(`QREx+v_-5ZFeCa^fB7O~ zoPE`H6pyy6^$HJf(p_!lc;-+W=w!Z+mvUTd^mKHOzorbY$mCH-)aS*mtBq3z3Ktg_nT0caxjuXneA}J` zQ+v!Q6JB`v_W|E1s38Z9bgoz>;v9u2;fV1`^znl1>}ogPhF!k~I|ok}jx2nPp~Q`z z$;7kjiuP@Bu{8-=(dDqR;CR(=Rt&w3A^}YkDqRm5F})mV=pNsj5%b*W*49j)%$m;w zPJ1liBth|B8n-|usXYyf|62=)Nsl?%VB0nhJ}Y;ex>)C`A26Ix#ngdx)TVvD--`$ewSQMRN9>~=n*Fh`ItD6a!-2Rp{o|b z*^$Qq_V|f5v<%nGmB)n+2D-Ay3k{h9Gt&Aa;x7~}I$?sgonNyKzYME~vp#;fA)WbY zKGsJRODu+2+uR(AThJ@X(exI;DGmTgq|Y-X{FP7E?^9tIGlDs+B{{R8zvPJ7F)@l~ zdOJ$;%8>MFiGm1hm4H7s0~ARaVw>#oDiSviTn++G!_F2I$?{vD_eG@zQZm91qGdd9D46;GXcJsVE*5Ol6J7L1Jo}Qss#hk30k|&JV;0L&}8K z+)dvFPi??xNdNaiPW?WFf@}p#w93oR6E3Aclk1v4ts}aLwa%`V z+s&&%`6u}L@q@7%k=jTRcOrK8jOb~Dg}e~pgKOG2fELm~UCU3N<^!_>rtY^Zfej)=!0 z_Do`ba6IB4htrjtrRtR6F}XW$0q3>8@xfw8wxh`yLeb!}VL=K+__38COB)>nYk+s)}#QgiqIi9KS>!|&-w zrG&RGgG0O??!%bF3anlE#^<=CUP%FFyLd%nlHV1kAd?B`zg_9{$9mdUgoa(UmKeT! z5*8p93t;*SN3HzxciAdz zR9TNV5;$EC$E(a*4E>F4#lZ!U4tg4<#v}Z&a%)MR9=ka;Q2gx;5oh2SCYpmfBw?YG9v3^-T@+1yDs;>6^ z(4Q}=^-u2!b;?fDu}RJgpIGMLMJ+<==hVADauQ&R_xVLC6bAV@G3lW(D1xfOI>u8- ze22TN&U_u(-iDKg+XNt6TXce#Cj0rQ;-2f zlTP9PT`aB8fWEAyMgRUw9pLcR)?TvMv75$i2hZ(-m$|8LC{>hyu64?1&|2ue-*2Z3 zyc>Lex}7=53mP|DPyX?r_`7YH9g7Mhbf2zj)=yHi60_ z6Z;W^TnJcF0|Dba=&{yip}bK|>^{m*k(Rb7Q9SVbR<$WeBYl}BOVKtqrb!(ICDG3I zEoGpOuBmC4TL`E>LB%(>x#`^(#zv0*Rls)?bs8lS2tQ9p&hl3{D(c zW(~H?9;Y|o%>ZM-q~2N-G-Gp%GEIA~c4F1Qz}a`d_D@L#oWt{bhAywQJ9?zq)4}T% zpMM@tO=NMa!jpxyf)gpM5WL16Ww8f3<#~La$jHd7C$k@xZO+9X4#`HwD1+Fx{)~^a zf+qkNm#U^#%d96d+>Ylny*KV*QriLZ5PVZq&Rt$pSNGd?|8T~Im#APg*lvX{6SL74 z`v_znNn=fd`VD^ZiFBewIXhayh?KAJ?uWxLWSKksK&z zr_p%BUrjp$_r@CUko9hN6U9Ggn;%XGyI!JnKX+|4em9-{7~R;Y)9y9X{gz$3@+P7L-RUK6F3MuxQb(NLU5Da^P|?9F{HLON)TH0;yxX`WD2p8yOr_BFvg>I-5{M?i zwS)ejpD7Gim_2mWdNQpG_#K)k^V+i<{Hv~h-Y#ifdgXDqG}U!ie7sL<)P8%CC-hGB z!1>+f?cvOBKuOcUzhUsvj4CbL*Ps*SNEA3AsQ{sPnCMHfQ-UjM7_#DOA zrBeD|7c4wi`rrOlO9C;l(^jIVxhjW)ysl>!PR_05;XEKM3`^cL+$A zq=0lvw{)nqbV`GCNtZN8hjdCwcT0CkcXw@SGZ(-2d*|;tX1-(Q9}XY%fxYj2-|JfI zTxT#cFf9Arlqr%fJgs<;ThBM9cpgNkmoSuMxL?z_>&8>q1aEsprT%iY z8!6?{y~pGRX?0IpkQ;S9Pl2zScAnpyoZPN&pPC=HODgS=A-vZAsL5?l02(utCe$-E z^-0^1k+Eb;4hj%DPBu0|-mI0GBmPmndICbikx{ObQ9Yfs^yDml=h|9f@27;kJQ@&L z<1o$iYl|~^5 zJ{o?fJ0@>%z`u#~lvv+4rVPnzn}Z-IauIlQc58t!?7z-iT2e1II4FGR}9HrM1BndVqtb z^SY(@ELOV+3$Wjjm>U~+^VB=HFM}-bPGMo;XRp`AXAy9Z+hZdhi@?n%V4;S}^62uA zyBCdY9XguX#%r|+0VW58#KedQcMJ6JK5QKK>gASSzYfV`$xX+51TC5j_pPSM4Ujp;v-j+NU2MRv*V;IkeCQN2IDX`v=Esokso>z2L-Grf_G4XLqBy+mEw;yYxT6NMjKhgMoJARR=5*6K#IRs^E})v-O24|g z0+?N7B*q;zGfVk*y1%MX>K*a%@pE(k&iBAVfduS1BJQg>*!5o%pthr0<@ZpQ$a84Q zoRXvqxTP#e(7`>8gYfE(fIZ?(nWXNwKWJO4E0d5554*El8_6u>tc;9?FU2JXk^ESh zhK*^?dbpSczreA?Lhy8!8;(M`xQ{R2+;PjH%e&f(nirG9aQ8a(^_N+43+z44hF|1MR173-r7soq7D;(CVUF`oVH2SD4%URQo5nA(jcw__$n?G<^bZ-*RNmSyR8#|BF{WbDFcd` z1u-f76ZXdk<4siB(yFt!+BjqqDQwo+!O|t2o!LkH*UVp_qt!}+omxb}ktCQHDCVWq z3YqVHvAbIo9Y4f2G~BKS`W!C!jIFFh^}SLR$eNnkiDG*9uC%Q56sT~Ifk?W%q{RQo z?(Oz|>tpZ$BZIgfEXZ^*Ff>%v(z*ubOMq;lrd&5C^xs&3ni^FQ`VS=A2&3t^e&Z@@ z{%f7)+=?Y&_w<8Q5bb*Q#E1F(=Awnbkw$>&+vQ(M-mgy`RG>Y~AShCMs*6UiEZb*1NpsHS8UJ|amh1lYDls_nrkyG{> zs0$~?DE-lh7nr!XmU{mYwcxh6KlYXZrA+{7OiWCT(>;_}2YT}P@{$5-8( z7^KhCzA^P9wx_~xj@EzL3T0dG9Eqlz9J&tBbq9lU0(-`tAjob)ei7) zeu>{v#m&_pcwCbjiv4|0mUaUPoupGtC*?9^J#TwW?6Z4n5AFq&cPz!!Rw$@);n|(* zu@kBUNb-~~?SU2g>4?6^Si6vR?NLz5r%!7ATSIey3)Q23Lkiw$rv5$U7DMX?3R-eo zB{sFqte0N;1^Py#t*9U{p~ZtiEJZ%`z0ljYfpn)=XZv%yF`pL-k?_i^YX$hq7I8-# z8hQCU{>g&i3?-BS7^=U03kUl(kj?l{-b`?es*oC1S%=HHBEW|V;}bbiCbPL2PUb4B zDvkv=p4ea$y|6k^OpyulGcs@2kLRtSqd-Dg5|;ba})^ z**qAPd+0Ol24%rIosqu07H%CFefpU_7MJOEzP7#|3p|U;SL)GQDI@w>+e*sH#AN|@5aKeWv~zeE zpQ+bgw?^IBjbb+Sr4!)-&fy`7O&ZZd=;MaSU|O0D6UA%%m1xRc>_vltJ~8z0@bFt_ z^lgxUoYcw@NP z+qBF5fCXMaonHwFLtI8ia%^nA_Zy~&4(oV3L1d0nke$b*+gRLD#g$ah&_EdGi*C!J z^p|7+neMUSB9RsU>gHmMDBS+4BPH<;sEi)(uDeOFihVgJ3nJBJ^o=iVdF>Sa-L7|M;Nb^{hlz!qH{)fD z*1Up`MG7jo5p#YNAfsH~U){7Re=6OLAaIs5=4Wce!^MRPxCr6shWioe1Fox`i&*^< zWd(I!GeNn16tw?SVR3QopFjF_Dg=1SdD%aUjG@zYZ9j-PNWO+3X9Wf}^meTwbsde- z{eu-c^Rm(l;pJf3VXuAs^~kIivW(%QW{fWYaJ_m%yLy^_p?VAn?_ z0rNcRsV{!@H>|nT2A}#q`C->ZluaCLB8jaN5Odu#FQxEpMSS$(X)={j&~w=L@D^yl>9m z+)S0xPNHjHeX1^w_0CYlx?vxqD&c2kY^1U@rY|fivH)X-AdU#u4cRHVL940VeTZYh z&m$EbMAYz<;$kqq0#(cet)KaxNNB}!)D_nS|1HT$x>qccBu_y>G%~OR(W<6H5)=DB z(68N8QG-BUFX*j$U|=A_{g%V6%gUqco+=s@l9=J*VC*+AYhnsPHWwRE=SBtwGI&nP ztEwEV62S=nc-=(IZ3V`e4`|}8M{0h#d3pB`SECIuI{+3_z5A74c{wHy?!fePPrPoC z7?w*cZSzqrji!v_4wTJgK)$ubb_H5k7>SHLTlqy@eYWUhl=XtMPJ;s;KmI$di85{A zIn&$QM~6l(gi!p)`6!5xW1C_tOSCJ~Z5!T7K84e3@Tc`0Gi3{B3BkeSiebhdUVM3$c*$tRQU?as1VzUGWEg4$+fEu z?rQt{H>9|j;n4G_h6hNg&(8;0K$O6S5iq|kP$bRpIDVV!c~CaHGn5<=5fBgn+!L&T zMHkpp+V;nq*tR|11t1&!a+i)I>4AdjOCdv`>)*e-g3MO1gZ4V_-vJgQ&#JVfpPY_+ zGi8j>3_izNy|&hcx|98XH5VCz9sso%IoqIvg@vUcxxKypaN0-S7D>o~Ld?dNcHP{>mLh?<(|cHhS+QvR6aWccdcF3c5wKm81+&A*i@IZ`40|LSb}i2PGi zi7!KJ#T&pvv(oh1~`-|;&lLf+Kt14iR z4)9MTXovFtd^P9`>h0`rj$0n@uCOOLudjaA);_hGbaZd_Rs!#L&`z#?ydx0u zOp43_7*TC?b)a^E&r_>BYs&Vh-dv6}4*^%B5yR9iScOAidmq<=xCcV|~uj&|L$ zMsfHLD;DN1&{s+~@E=>#s{m371OX}~U6=gc38?Vq1CTeqk((PDvU@_5OK+s5rGb{o z&~Gxu$I{q1H%;RF z4FH%!x_vCb5Q%%}&h*w5*Kog4le*EWwJF5!uG7KB8VB06k<}+qPxox#33kX&CQzS>{uI>n>DDNklMH&bH+ca$ zi`&~H49Ab-2T~ed&~_t-TL#x(BI)MpuB+QQaTp-<;aDxy`pWqG`T3y}b3eP4dU*V5 z$Hra*)v%Z13!@C7s5>-)`Y)EAOB~V2$Oxw3)TtvfoG3xGX)N`e(7+59)WPz@pNxv( z7P;pFn$)ztyj*Kd)x8U_AzQ^8Zc(h^&~H>POTRR&6%}Npg7qR)C9O0@6R?`tK?9t~ z+%uH%sO;v}qps)0So~_+W_?n|B?LT2fVBvI;7@S?dU-a#DIQ<%xd1zL@8dJ~*|MgygShzkKj23-RaMFIJ>Fg<{$&2rUsq?n03T>L zkuQlGOL0sOer;DU=FwsU)q}LzRm~Gn$iq!F4tDDk_&sI z&F0X;MtPZ=vt}abwtALX&MGR$&1pzh^yTJmR#Yg{N`waJ%BR0K7|ViAPjd;tZGBV{ z3~IxnizH>uvQr>BQg>q3w(2J+<8olcgw_6o=WaQFD= z(&N9X(JFzAg98w!Ls6YX2TDTG#qvKDJn;d+bwSkAv$F;H`I1sWIXij{uTnGhzPAEv zmhQQ*+A3Np*_K@4mHaq0?>1s1|Vc|@+oM63ryN!49tgS&9q#@<{Z-A5PJ~r4@pG%Usne_dTkkSb=Yar z%6UEEiNF}#)wRA0Yr}$-ZX!n9)b#aGT-}Pd-Xd`S2`}xA0-ULT)n>v!NUixoZp}=M ze^J}E-ey4XW?5KRFrhI@$T7l-`+I96F6S#}VhQ!;`^@h|7j6Hr%6y$3K%amuKzr)Z z6!}i>DFc@OL040&l@C{Ml*xgYQk2kLW-n(_A|L|cDP{vFD9|E#T?{L|XO~bLACJKl z;*~RL+s)p#CHc;R{4YK+anuEP6`dO;UyHr!_L3X)sG-j11B0{mKYHr^?rykcLr*K^ zSPJ9Wa=Oklv_uYC8$bsOl7tnW3MTiW9Qh^URAb0Dy0nDuU6Rg!vpa%As|~2+K#bc2 z)>JoxEIecl;0~<^^ew>U{gxDC@Fpn!w9jZD^y5W35seHJHC4!c!p|FlbA4#nP&oKt7o09OO zhJT#hNyt$vUWgT2YY|gHz(M`h}l4@$Wo80=~ z-7U(M7>swh>1v)cMJfhL(#I*m@Wse_FT!O^4c=r6l|HcAlesb*KoOk8$?JCh zCp=tES^0&8BUt|ANJs5Xm+(^|WzSpC%4k)ac7aJnV|L13BozC2mT z{nA)N1Kcrx|AG@_C2wpNNKMT#Wc*CN9nSS5k|mLmkc31*p{;;nDde<|_T=0Z^o~w3 z?dPD0^@5aAGCP^W3#0wjj-2c3S~fOyN`sBQDEn`Jo**0Lpt+O5j8fw@?w?4A#K6dr zLg#nfegGsE0JnmDk?BYpXrS&!g%1MjwCE`37i^zy+I_qDoe#ly56%VS>%+aDKVQqp zyshwO_4#J^Es4vK!Fad4?PqRoZhl^#UgNjh1oe!k<0d_+9|V4ACB?;0P!BgNtLF5U zrSDKL_nTv1fwsH-;M3EY0>$*Qk`mC-&5r$ot>mDjw7)%{4h)n8bM0_4M;v}EMF{N} z$+n21A{jI&C@91On9Z%)8ZXwJ013WyfkG-$OTFFNdcML!(z?l4F7T9rqghGd%nqER zBk3N%#Q~S4S;!{_**0F#=lIQ!l*>!B5V@0OK~NcgZN_sF7(!m}ji&J-R=V!~(i}tD zC1v;7%QFMX@PJO|2N(XNi$UnV7Fh83==mDtw+jODRzN)Ph_fF_BZO$Es%mL#hergW zCW6zgtBP2l@Tt)$^|ww#Z-9%E;ZGiqYffOkK-zRW*aSNKf3?-v{ z74oX8t1|-}Q+-|0Rmi<9Daj!vyAA49MEV-VXO^*% zXJv15Q$t%jI095kz+S;dra%Y%K=33T2h{;^Y~We(R2o8m@7H#Hef^M=m}u7K-IDlD zJHiL>6+h<7S5#Imw>^zz2;LjW?=d@id;2Qq5`SdH#B`BA=zbGQ%(`2_X4D%$T{251 zq_RX?K-1AZ+#b_NN!MCQ$ z{mNZFaCLv=?_Zr}SHe2;)mQ&&EcH}Wu&wBMi+g6jTEc}P+@O2TB^8|+tvv<(3J`Gl zPl5L-%ng3!^>`HpTmOqru))E?*<`B9+@Z_TR53A_Y4>0|V6kUSUIKZg5ODIPOp?C8?j9m5_u^zpwWY zf~-20u6N}6sUUcud+!345BiFsxcq#HdRP7xmsaTVHXNobrF0T>I?Fv)?TRk404D4f?m=#OKxwQqPOB| z(@P~pCc5Kvdwp=03IeG@l`r0d*-z6ynDb!4Muq7(MP<#IY~%GIt|ck5yu#-i#V@mtvV8A~IHOplwzQ`}=w1b0#_YW84zzfF-L>V1FU5?4GQuUtGmpbvp(XUL^ROnR+f&(>+sY5AS@hX8l;O>46{ z6_`;j_qp@|)p?BNKR-4$ce%x5^#t&|A$_ojxEvioP!Ng?NLj27o2*x`bvD4Pv`bjN zqB_F|x&>f@O8%kKHSzKYMD&W^M+$Wpn2B5 zzdphs7wQx~p06$k&;gj=fG$`5%)|M$i!$vM@8lKtS{5& zcB>B1M6DMXmV2(jfIZ#nrJm7WWx^QEL_u*lE}bd+p?<`r^>U)*>3-!tO7QwmaPSKB z5phcHe6K)n70cL<{eoxm@$qJY&4||f{s^;7TY62Kzz}>&GIDZeo!Y5F52qh--!c?$ z5Zg9Koa^tDl((L>J+-K7X$8r9L*1AGk|d&~_Nvid963lV6vDl2k0~sG_y+z717}-S zevRoQv<(0-;y^Q6`IzYg{c2&q}^b){);UMNdAQ>xkKbPx%c3U=q4p;-KXv2d2jBK-`s zZvI)8{~%y11tybhY@uze7!wy4qisS^2-VZ=Xt>W~4I~gBN)>Og51vefs?Q5i}^}&F8OH+AJoCbiXHoh(aYdH+YgcpU11d__tyB zx9f#q-R^ZYT>#V^0zgK?)H&+1%>Gn+Lqo&OC0CJ<5=GOsRokLvV- z-*(%;%E_S)E$Qn3L$4?THe3&Vo7I!!C6Cp2(6%QJxwqD^>J|7JBfK7Bvf0puJLKP>q)UJS?bufFw7eQKc{L9u8Xs?nV-Du>@Yq6)f-V?0R$XJ#|ywN5hDhZ zZ;*ZWRfsTmzm325C9bg`jv4TI;vB2s1c|$fWJp$mr;rP0p|r8yjGbX4}(l z^`x2k2Zb03dCIIiWMEXtUk`DxP$0_pEqh*&GOZC4TGaeq{TO_6ce|q}WVcEY7E#l@ zx(XlI&n;lh!(U(IZPzCI8x@VD^a!z5YA&*wn_av{Bk=}MbCCPLfhvwdlD zuNi=zo}c?A>UsaFs6b+Wnc&+CY=CG9CiyI37bzo1PF*H)&lW>}XUh(di zJ{XK=qP=!)X_uACBbFbLS>fbxfKL*Jl$I98S^L6p!_MbPcIaCA+J;M-cRP!=M^NjnSj{Z21w!AOpsY z|J&wnE()>WIYqaMHAmK1<_XFGGt}oHlf5!kwMTU(X=`&{@ z&j5~*)hXU z@W|U9&Qr_Vpd;VmsX{!0w!Nc>XLA4$knVB_I9Y2{+{^9yY$URT{!NY-%h4p+VowVMo2%_hE zpS0Xyw^}c~-|wf(1WJ{idjm6t6waltE*4Pz7i`Kbo!=mbcz9(b3(2V^K}GNS=1mwKOS9yYBa> zzy&qR#R$JG0wSV;l9W_XO-+OY?dQ+a05k(cn#apYOrYD+bK7##)=r(THUsOu(dZe} zdRuZZvwtr6DHaM)XjKgj$fQymgPf|SCg0`mte#IA&}axVX$ON6&yNejrfxqkRfBe6rgrTyr5{to$ma&}rB0js=D*&S#>kRZUN|t}P z1|mkt0)Ww>Z1nW3fKvsem;m4eEaT-yr+ZM`&o59%GX!Vz6{wX!NKenzhri94cZ6W& z!9}(}uZ-}pC1i_5Ozg6!Di3_BfLk>{QbGZ%(;pFU*^VMr>A$gnY%pjonG$^)&(40c zCCEw-@rI61OsKxC+9*B6f<;bWp`$7KJ;;Z*}Nj2#TG z-dOP0ssNj-_Etwn#|`qz@e=h>klYP^-wyyMx0dD~eh3RevSp2U4~#|@TD{D`cWz;iXYUlHjDfX+xp zUOp_8j~HP6j{rHW*ZhlgI0|%<_V#Op#rQG{l@V_kiWMv@=H*xf%|Osv0AdzJ&CHA% z;1qBi|C?N3X}J`DR!Xs(4$T#HzCDH1hhTxQqI?ue}XZx4YJM%S~y`JXeU6xVfhZ0nxsqA%1)1h&{OSs&lhVfF_l(I)LjTz zKoiFK$qJO1{YdJpqd6&H9_kXmxfYx$Qp9=%RF_OHB{;wY=nqJ7ud`)YLIwcB?Wph| zkj~DRudF&BHF`dr6@cn+C{5nNLYws7$w5lWG?BTbViAM<8qjd_Wie3kq4Rli1u8kT zmxuGhEG)K0uJ!gcxdPbyoB5Ehb!zj3kFwOSml6@^f>qL9;tkin!u_ z!1erzsSzdwM^BN<`l;D0LNEvXRj-Z(u_i}*!go0Q>Ytwd-mF=%X=NQ6bZC`zb&rpy z&0UZmBtYZ1hN|>$l0?zay;wFOW{6fV@2$pfA*q~J)~( z1lRh2`>O-MLyCSeQ4^aIj7=2;%DxsmZfC?Z`1K#a>;KOaNWxK0RYfI6ekOJqK0Yli zHYH``8128v)bg_3eR{$k*Dy^BI?V)Kdco72uW;fnE>H=^VE{}9Ft?|NYUnV}%HwVh z%Zls965u)KWM});z&RhzRlA%(rAQqf-6#6v3a(_5|BG$yrTQ8l-*%z)zQO=hN07b^?~0eB+MZrx}GQSsd(g!forM-80(^LZJ zkX{dGO)c(DW-j^cspQsmQOX5;hPE4X;ATuu<|qI!I`ZT#GxP0f*C;60X8;Qaj}ztl z#^LcCI(;4kqwS%$w6wGzs_i$_3X?)Z?EzpFghm)5SW%s2`1Y*@@O1*3-gH)jAHd@K zq*ii%rgXl21xkRoWZHY#KfuDm=W^WntE(?9J*{GGcbCTujwVbg+Lq!$zxPC_X(hyA{_xHtmA52YW03xggpdbNz z{e@Z;fiAEnFHDmI+%Yb@(^VBmM~-jjH!D61Q&XEKZR$EruK@`KXxl@$t=~W&9M`~> zh!@TyS{syNS(wEd@POBP-2CQcIX1n&9^c3FyOZe2c<)sE`}c3#6L)Kq*zLfucF7JrF1fgLbwhoNdYPC`=*Lj|Fxb9@aR&_o zC=@Q3YU__hoIP?q3n=`Uf}D(?%)bSA;`2ZTl=C4WtW%)?HFalvM^CTZ>QyMC!+XrT zUj~l&9BE9m8)k)&mL(FkSI6j3kUbmbK|*qvo<`CTg;0ig5%|3D;?`^RG#UI!D!*$5 znsRYD@R=V1Tyr$N>2Cl&$}jupk<1q{U$5B}`BC$h3CIK`9DOOdJ+3b=c9^rvfHeKf zm$>*OozI`IhB%_0T}U(A2!0X?^IsxGy!v9Y(kc#)h;(ql9sWNUH9D)bv{}qGCZ_kz z(TWe!eSv)PcX8$~VIAI=0VC=BwO_Zc537~dP3iGp1W|S)!GqMzyQ8{W;7K6{1&&_% zN`hB~E$K^DCMBG@NDlr%>x`R(L_}}4iyW9Ew$zzlJQy-FkAT~PAlKk*ee1M#wVCw& zA4%ap*2s~Kt*!YQ%K~XKw-H3l1l_wcS`b-jjgm-tD}?_EI%jibv7NyYya2Gx_lVuy zLeN1vAIxYDVGZLy>Bsz9(1*hzGC|zp5Gw$D3}5@9l@(0ut-Gsk=Oc(ruwxS}EdKjf zOk7qV&kZH+snSR~EZCCi)ut7Ie?98~7)Uop0>*KQ`kz04>gtjWm?9nLq;=dy_&%!G-WfIZ$Amo=W>5jg%u9nWk>GhqM1-5`y;y)@QA zb#82E(9qO0;uN!%S8EQML<;Jv-7aBoZubXI1iQx70~La2f}y@?_X>I_^kjScXL|bJ&BDS;lJ;6I85u03 zwyB99;p?{-XLb>i1H;33CSs3228>Nix+K;eRlhMT8z*N?J#5thp9Xi`5N^(&dbh)k zyKA2QmED?`_bUPMDBZZJTnMPB;XqkmUsd($;}_lx<#+24 ziHhwrnqS!ZUu&I@qzEcc-8;>nHlo@*y}VAN=-Lh8Qjk)n!1_yE3>W8R{Ra?D6U-f8 z;5?q42OI=!PnpcMqd7Pg=J1h`L-&zcnT$S2zg+bVLJV%AOYlu~OI**DWZhk@4$%8} zs~sWfchq9kM}|u#c(h>avrP;8k0-5^L5Z*3HsV(T{)@yoAp3;6X7iR;;qxoT zOgWd^1m8@&kI40|_RP4@gL0qlGK)_z`8R@oW{52qBltQjX)ItsCA5MYDjPdmlkwH` zjk0@}1H(o3+tb&a+m1imboEqK2BD5K9)2a;{pLiNX{6`BuFu(fw{x`LTLTG}G&Jbt zx2Ho!hQB+N#U?w808WZ&{WigN1so^(2OZ<#Zp_rNbRtI`GJPSY9yOZ)ryY}h&7#{& zO8?Rq8YRRot%E5hetTGLYo})*>1c+yD3iqRc{z?RpM(wk7PM%@uhz(aPp^%XIl(WhU^NqdI!cU z4E!s7d-%~do^E`|pH6(ryYM~?gJTEIQD#o4wl=AhOCZ^A)Eubw9Ct>aX=;b91)6&85%Oa~tONF$7(3}MLfysa$6)$=w^%Z;$F|;4)gCf@iO^L9 zyqxX=;Dh>?TM+fHyYy$+at!?VUu@yZLBL(x;SbPOeNxF{{O^2ORdG$+pG00Ni_PSs ziart}7ba>7B+A}8!REZcc}*HhET8oxD5ge$7N04nA2ujqt--x3uhJ@FU6+K6juu`F zLaZtkr%s3_R*wryyEsFj(r;IUk}~O|TvZ^ld8WtcxlkCJj`G9eGsbug$*Au|MHzwz zdPG))=mWl0kwd9<^o)$Ca(^4cjo)NZWlG_F&p8wOQ=_%zXu)4DMHC;4fV1lp#c66{ zU8ZhLuNNPw{R1Jt(w%E2^zKX7TEujY6ltF4{i@2u^|Y5_wU!K#FY4aYJU`AZeHdq? zi;!)N-mBsox9;y!_6luzf`me`kyCdf6-HLd6KIiZNWF=nu` zPtp#hUM@8PV@PBvx@*!8NfZ~e$xKFd=ye8&utx8Zlr6A<6SbrNLXFfZrTpQ|^H;ET z2>x#@;J?1}zx*{GJ1X^4NbeW@oe>%{6O%o1ED}$W&`=pSH#gb-n4ivjqj4(@0Zl?@ zs;rmu1u=3z+1KrH0?pm==XNrs)ArvSj{{*`cf!Q1O>&=gXF%Ib`4iw=&4OmP&1$}n!%We|f(|`VpPBXFMaMgU z6)W14gQX8>oqaCHmGk7gh?8a3AwP996fFX%shI%p4nSM_`+u}*z5-IySp?6rj9in3 z(nUQVn~!~HQfqr@skW&Qi|Gb3a?$+|2keTEk0#ja5_5fB&9yQ0<{4+F)f#d|Uh%?z z8WV?t#}*wK`FQ1N&*^}B8Q533UE~81x`=Un#vt_39^~3dT4%bhpMf?8nD@}oJ=Gg@ znSLw(I$az|%+n|p@$~~#_})tV>sPJY^DU2?NV7M}z!GM-0QXh?S0~j+Foz=$M1~NPX`1&hr(t$6%&Q)QJUn4=elj-=?G@ zeeR5=W~Hx?zF~G{5qLX8;zFUPw*qE?57Qn53^t1d2}!OnJJWe`eGpjW3q3DMJ()Ci z_Qg~lpWhlq>q+BOSUMV1i142Q=i^(8J0)OwmTG=n>c9URWAp(FIOm>jCb6xFiuz`H zH)4=(Oyp(8#wr4z8b7svfq=`K{R>=RY%-y#LmW1OM^~ll?B8)=NQ9~_Sx%Mdb4srL zr7!W8yD#BWZktc_d%~0k5^qeZD7Il7b$NgxqXfrP{qs{$F%j)pSZZ3uE zNz^BW!_0KL_;Yr4?RqkJWk3idpDA=(RL}@qy+{*-vyU0-xas&2^PG0aFnpmSq)vlh zi&U+5Mq1Xr=dx8J1bwcDZe`*!tmmtG67_7FN*ruK>kT}t#84?(H*)8g>q^wi2mjUs zR&;=_lG92R&0R40$Op?v0}5sNDG$Aq2ZS>ud;;cQwrxvK;T7Xo|37aMraRRf(f^J6N8Yj({M6B;tWz^&CkxxE++2Z z<7hwzXB_xJa*$QZ^i~O2zuTxlWCc? za_NS?(|;~DNczD&K8xdolkoMhZLG}S{~&7)7V*`@WH}(5{eq8n$)drItx%3mOY^Ju zpEs01Aqfpwf4IffvE?9gf4Kge||yMl%Nv3Cnqx_E4% z?99h6&ac7bu=cfsD{5Zz_sv^+yNVvF-yUK%QAl}7^wZt%IP+w=Xk`t)QUPhEo?d2i zvuBGF#;|IhY{FpTq&+%gF?t!14?bGtsYq!9R6(+caAZA3+M6Fmt6*q}7Y&FCCtgj%t&ew9}c3mXBF$ z718?x83DU~Qn&FNA#C>Nk{Ko)Ud-?pA81$`{3ChIMqu(zVWh}{F^bI@7p+eAg|k1! zk%daJnvWNMDjL#_wEVGTG)BQx6~g7B8v*JcWt&lEOucGk)QbzQq+!v@*>bbyn`LR! ztt>9r?BU+f_dJ{ndy>hs=*B&q9&Bs?QAHlGVs3kw4RV&^$qQn8{pa-bq93t znWDIw8o-aAm@cuaQBOFECcA!LqeH+(DvyLg+=hS6;*Dv6t@?vjfvD;w{{HD{*H=#o zm`-RCY7C$#%piRpAJ;STMznzOz%NoopKL_nO8QhpTgI(LA>8)eVjP1^AYj)&l)IT^ z$8R28-eF@4zziLvx>}AqzyS?j=H>57z+ENSmd3S)(N%G*EZ*g|8u^RHRAPV>R3{~F z@EgDKdmwdga8VKdFmz%dEPDivNoBKvViDXeHiPU=wb)!YZZji zif>A9)67KcB^=MbnL-0}Qi2g@^qMx?wNukjNui57#hDW1$VI zhy;$kv#j~S^8r2aZ#UF`KimKYK&1Pdot|#23|vd*KsmgM%OJvN;1&$IzojJZER^se zKnO}218`qL7=wM>Z}KG;Bz%Tr5deov6?9^e4Wb_2DyIJfya;T$#_-pYDCJCzE_@yG zU+BV+_*5A)B`ZmV_{D`|W@K4HyNa_57}pk0>>d3aLz8ea6P?oTm%!auN|6z6E;9K; zpU*uix^~@BodrfLy7N2!Bf&4@{#Q=7$?sUg;73w#h<^40B$p4LGd+k=TUWvGaPc6vZPMf?alKAN7f+TvbnJc;{9rREGrJ@2c&77jkrsjVMY4 zkXY!|(1T3kfsI&R?YCy0>+Mz zwi<#cTvQ?c#P~e--2L}OA<R&WzQbGyq#Y+Bja)(zWnabzjH0KWnThHQek z5PdZcq@whwHyt7r2YYI24|;S@a=GPEy}48tnE0Rn-Y&)`z>iFS!8O->+Gx<(d(?q52vkLpsWy|YdZ$SU;5)tg(-Sq_-PoLc zz>p#LtngfJ4g^Q}viE(am9$C}1@O(LTx0b()I}x|t;csUk--S>W0Owk0Y-B!yd;r4 zD>@afXW8xT?Pc0eC9X)IgY4_0tLOu?^6(IXGOTXc&{P)~*2zM)XbnP=&)IcydRpPH zhuv>}57d@*ywq_Uh`FnlFd(dU0SpJXmDWc*Ml}-FETw8w1%QZ;*IF~ydO>_)5i!XG z*o}Ip-ZinAuBHo;8N*e;bOK_S;G8VC3sbOO14O=`M8hC* zN+kmHiHVFmOGO`pD=J#=s=09@DAkuLjb;H_4Z6Ya#di>h=UI>1Fm`-#&8@90nm}e^ zV$_c9@F3~_(p^mci)z(`AbeY-=qkrVTE zB%~@hbtg8Lhzr_=Ae}I^kcT)c-V-KjHku)w85tga0+j!2FUB)z@P4v+sLKvS0b*ex zv5whBFMadtUksFMNQ{VQNNrl0H=gt8b6M&U2ZlToME>gz(f^OE)N6$$f>C4y-nGI7bTA& zlHQkVzi>sB=m#s6ELo%!b3!5_Z0Ffb@6tNQNljsolSX@b8vde+WXy~X_(Hg*o^1;Uyxa}dk5>#)7v^VT!3 z>?d_Fnbrpb-f#|}!z2mtiCvg|G3=}w8(V!oO25R$oiJ_&qZ9woLPt9Y4oQx&&-v0c zV_|{s=e`f(XxCB6wA92iL zb#ZcXlJOIg2=&#?O`pYn9h^ki+2y2Q%q+ze1`+3rTWpUE!eP7D0F4uL+%9#Vng56U zdiAf%iWe0%_1qYfJm*ZkeOE;VFpDe^>)r^J34sT^P*jZH9IU0_Ld!TCSDQw}Kv;!i zGx8OPb7(=Xbhpee&Od0-&zx?df9NY^nMNEKH35eeS7DwT@XI(UU`S$_Jf$G<*aI;F z+44f)ohGIv2y-qGYBEPfpf0QBNMT+BiQoQ* z71odWe}9-FAr=lzYIoZfrCRh36{sfVU*(MvKB! zVKUx2xUT_$ukM2`mTdH){w2UH7#8DhF>+0zOez|tYA-jrm_8d+(H40OQm|JVni*Bt z?AtLnZt zZ0wSLMuJnd#Wy>gJBB*2J%E`Q1L^ZEI7f2-%}f1nd%OQ#(ymoChw z@YwoM4*9{`4rdAnw>6IuU1JLL>Y)8o)?$?8$T2XmIbNK_RqzR!Kk>H7?CKoT5cJ z@Fwpoy(q1p2iF}*X1CLCl?^-T1jFN_bqcx8Gt~u5VUbHSnLv}*_P;%F4o&Wjm$}l37v(>fpFygpKM!;`XH2Bya7G6&h8~xoNNR<-F&khA9&;Yw1rKdmE zLC}rojUR<9B3ziTu-y51CycV9Vyze!+!`(%ic`y6PvNHO2i3uAX7%49_omN(VE`ir z2Ls<3_&QoAM0Q5(m0`?X&SI48^0JG~k}8rPg}g|JGCp)VSZi0GtsCa2SSm*vs`hG&(UES6U2R+Jwu@PX5?FAEFJBtG><~r8*3GM2cYZSYH$m+E#7E^XWd|op z|IZcuKY!hkib#c@6Dy}juOglyJ{Z^BOoHU=y7J)J#{#%AZBQaCTw@57S`-Tt=|d!I zW_oJsK^`sRA*%J)EM=WkY1K-SSPfn=kxpuN-!8dSxf=!%eN~syzYQKdH@)ais8hkv z^#gUXNv$48iGm-6K!~3o%#gv(a!X=d83XY-D#{cgY3Uu6l&ht%?$`Qy;g{BY>Q%2} zv7%6J3?w@+j?|CJUuryvYpB-Ej0}H%(hc>)78T$%zxSutLOM6Sjj3}n(9KPiVR%5z z&aT9b^<=W07srA-lg|1PM1e#sN(r { + if (!isNewThread) return; + history.replaceState( + null, + "", + `/workspace/chats/${threadId}`, + ); + setThreadId(threadId); + setIsNewThread(false); + }} onSubmit={handleSubmit} onStop={handleStop} canStopStreaming={canStopStreaming} diff --git a/frontend/src/components/workspace/input-box-helpers.ts b/frontend/src/components/workspace/input-box-helpers.ts index b4a8959782..2ac8bf3919 100644 --- a/frontend/src/components/workspace/input-box-helpers.ts +++ b/frontend/src/components/workspace/input-box-helpers.ts @@ -1,4 +1,8 @@ -import { RESERVED_SLASH_SKILL_NAMES, type Skill } from "@/core/skills"; +import { + parseSlashSkillReference, + RESERVED_SLASH_SKILL_NAMES, + type Skill, +} from "@/core/skills"; export { SUGGESTION_TEMPLATE_PLACEHOLDER_PATTERN, findSuggestionTemplatePlaceholder, @@ -204,7 +208,10 @@ export function getMatchingSkillSuggestions( name: skill.name.toLowerCase(), })) .filter(({ skill, name }) => { - if (!skill.enabled) { + if ( + !skill.enabled || + parseSlashSkillReference(`/${skill.name}`)?.name !== skill.name + ) { return false; } if (reservedNames.has(name) && name !== "context") { diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 6534c88746..326f7e5ba3 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -4,6 +4,7 @@ import type { Message } from "@langchain/langgraph-sdk"; import { useQueryClient } from "@tanstack/react-query"; import type { ChatStatus } from "ai"; import { + AtSignIcon, CheckIcon, GraduationCapIcon, LightbulbIcon, @@ -22,6 +23,7 @@ import { import { useSearchParams } from "next/navigation"; import { useCallback, + useId, useEffect, useLayoutEffect, useMemo, @@ -75,6 +77,7 @@ import { buildConversationReferenceMetadata, type ConversationReference, } from "@/core/conversation-references"; +import { useConversationReferencesCapability } from "@/core/features/hooks"; import { useI18n } from "@/core/i18n/hooks"; import { polishInputDraft } from "@/core/input-polish/api"; import { @@ -92,6 +95,7 @@ import { resolveReasoningEffort, supportsThinking as modelSupportsThinking, } from "@/core/models/reasoning"; +import { attachProjectDocument } from "@/core/projects/api"; import { useStagedProjectAttachments } from "@/core/projects/composer-attach"; import { buildReferenceMessageMetadata, @@ -131,7 +135,7 @@ import { type BrowserSpeechRecognition, type SpeechRecognitionErrorKind, } from "@/core/voice-input/speech-recognition"; -import { isIMEComposing } from "@/lib/ime"; +import { isCompositionConfirmEnter, isIMEComposing } from "@/lib/ime"; import { cn } from "@/lib/utils"; import { Suggestion, Suggestions } from "../ai-elements/suggestion"; @@ -142,8 +146,6 @@ import { DropdownMenuTrigger, } from "../ui/dropdown-menu"; -import { ConversationReferenceChip } from "./conversation-references/conversation-reference-chip"; -import { ReferenceConversationsButton } from "./conversation-references/reference-conversations-button"; import { abortGoalRequest, beginGoalRequest, @@ -164,6 +166,24 @@ import { readGoalResponseError, type SlashSuggestion, } from "./input-box-helpers"; +import { + inlineReferences, + reconcileConversationReferences, + MAX_EXPLICIT_SKILLS, + referenceToken, + readableReferences, + restoreReferenceLabels, + readReferenceEditor, + referenceCaret, + focusReferenceAt, + renderReferenceEditor, +} from "./mentions/inline-references"; +import { + MentionPicker, + type MentionPickerHandle, + type MentionSelection, +} from "./mentions/mention-picker"; +import { getMentionQuery, type MentionQuery } from "./mentions/query"; import { useThread } from "./messages/context"; import { ModeHoverGuide } from "./mode-hover-guide"; import { @@ -295,6 +315,7 @@ export function InputBox({ isWelcomeMode, threadId, draftThreadId = threadId, + projectId, draftAgentName, defaultModelName, knowledgeScopeControl, @@ -303,6 +324,7 @@ export function InputBox({ onFollowupsVisibilityChange, onGoalChange, onPrepareThread, + onReferenceFileAttached, onSubmit, onStop, canStopStreaming = true, @@ -330,6 +352,7 @@ export function InputBox({ isWelcomeMode?: boolean; threadId: string; draftThreadId?: string; + projectId?: string | null; draftAgentName?: string | null; agentSkillNames?: string[] | null; agentSkillsLoading?: boolean; @@ -369,6 +392,8 @@ export function InputBox({ * aborts the command and keeps the composer's text for a retry. */ onPrepareThread?: () => void | Promise; + /** Move a prepared new project chat to its durable URL after file ingestion. */ + onReferenceFileAttached?: () => void; onSubmit?: ( message: PromptInputMessage, options?: InputBoxSubmitOptions, @@ -392,6 +417,23 @@ export function InputBox({ const { locale, t } = useI18n(); const queryClient = useQueryClient(); const searchParams = useSearchParams(); + const mentionListId = useId(); + const mentionPickerRef = useRef(null); + const [activeMentionOption, setActiveMentionOption] = useState< + string | undefined + >(); + const conversationCapability = useConversationReferencesCapability(); + const [mentionQuery, setMentionQuery] = useState(null); + const [mentionButtonOpen, setMentionButtonOpen] = useState(false); + const [mentionBusy, setMentionBusy] = useState(false); + const [mentionPlacement, setMentionPlacement] = useState({ + above: true, + maxHeight: 400, + }); + const [mentionError, setMentionError] = useState(null); + const mentionEpoch = useRef(0); + const mentionInFlight = useRef(false); + const dismissedMention = useRef(null); const [modelDialogOpen, setModelDialogOpen] = useState(false); const { models } = useModels(); const { user } = useAuth(); @@ -400,14 +442,22 @@ export function InputBox({ const setTextInput = textInput.setInput; const sidecar = useMaybeSidecar(); const attachmentParts = attachments.files; - // Conversations attached for the next message only. Not persisted with the - // draft; cleared once a send proceeds or the composer moves to another thread. + // References belong to the draft and clear only when its send proceeds. const [conversationReferences, setConversationReferences] = useState< ConversationReference[] >([]); - useEffect(() => { - setConversationReferences([]); - }, [threadId]); + + useLayoutEffect(() => { + mentionEpoch.current += 1; + mentionInFlight.current = false; + setMentionBusy(false); + setMentionQuery(null); + setMentionButtonOpen(false); + setMentionError(null); + return () => { + mentionEpoch.current += 1; + }; + }, [threadId, projectId]); const removeAttachment = attachments.remove; // Project documents attached from the shelf arrive already ingested // thread-side (spec §9): the composer shows them as completed attachments @@ -416,12 +466,72 @@ export function InputBox({ // keeps it across a Strict-Mode effect replay. const [projectAttachments, setProjectAttachments] = useStagedProjectAttachments(threadId); - const { skills, isLoading: skillsLoading } = useSkills(); + const { + skills, + isLoading: skillsLoading, + isFetching: skillsFetching, + error: skillsError, + refetch: refetchSkills, + } = useSkills(); const { data: uploadLimits } = useUploadLimits(threadId); const promptRootRef = useRef(null); const textareaRef = useRef(null); const inlineSkillTextRef = useRef(null); const inlineSkillComposingRef = useRef(false); + const composerCaretRef = useRef(0); + const inlineRefs = useMemo( + () => inlineReferences(textInput.value), + [textInput.value], + ); + const hasInlineReferences = inlineRefs.length > 0; + const conversationReferencesUnavailable = + (!conversationCapability.isSuccess || conversationCapability.isLoading) && + inlineRefs.some((ref) => ref.kind === "conversation"); + const reconciledConversations = useMemo( + () => + reconcileConversationReferences( + textInput.value, + conversationReferences, + { + enabled: conversationCapability.enabled, + maxReferences: conversationCapability.maxReferences, + isLoading: conversationCapability.isLoading, + isSuccess: conversationCapability.isSuccess, + }, + threadId, + ), + [ + textInput.value, + conversationReferences, + conversationCapability.enabled, + conversationCapability.maxReferences, + conversationCapability.isLoading, + conversationCapability.isSuccess, + threadId, + ], + ); + useEffect(() => { + if (reconciledConversations.text !== textInput.value) + setTextInput(reconciledConversations.text); + }, [reconciledConversations.text, textInput.value, setTextInput]); + const [inlineEditorActive, setInlineEditorActive] = useState(false); + const projectReferenceCache = useRef( + new Map(), + ); + useLayoutEffect(() => { + projectReferenceCache.current.clear(); + setInlineEditorActive(false); + }, [threadId, user?.id]); + useLayoutEffect(() => { + for (const attachment of projectAttachments) { + if (attachment.source_document_id) + projectReferenceCache.current.set( + attachment.source_document_id, + attachment, + ); + } + }, [projectAttachments]); + const inlineCompositionEndedAt = useRef(-Infinity); const goalRequestStateRef = useRef(createGoalRequestState()); const compactRequestStateRef = useRef(createGoalRequestState()); const inputPolishRequestRef = useRef<{ @@ -444,10 +554,19 @@ export function InputBox({ >(null); const promptHistoryIndexRef = useRef(null); const promptHistoryDraftRef = useRef(""); - const pendingDraftSubmissionKeyRef = useRef(null); + const pendingDraftSubmissionRef = useRef<{ + key: string; + text: string; + skillName: string | null; + } | null>(null); + const acceptedDraftRef = useRef<{ + key: string; + text: string; + skillName: string | null; + } | null>(null); const latestDraftRef = useRef<{ key: string; - draft: { text: string; skillName: string | null }; + draft: ComposerDraft; } | null>(null); const draftSaveTimerRef = useRef(null); const draftSaveGenerationRef = useRef(0); @@ -772,17 +891,45 @@ export function InputBox({ }, [cancelDraftSaveTimer]); const scheduleDraftSave = useCallback( (draft: ComposerDraft, key = draftKey) => { + // An accepted attachment send keeps its text visible until upload finishes. + // Reference cleanup can rerun this effect meanwhile; do not resurrect the + // accepted snapshot. Actual input edits release it, even for identical text. + const accepted = acceptedDraftRef.current; + if ( + accepted?.key === key && + accepted.text === draft.text && + accepted.skillName === draft.skillName + ) + return null; + if (accepted?.key === key && !draft.text && !draft.skillName) { + acceptedDraftRef.current = null; + } if ( !draft.text && !draft.skillName && - pendingDraftSubmissionKeyRef.current === key + pendingDraftSubmissionRef.current?.key === key ) { return null; } - if (draft.text || draft.skillName) { - pendingDraftSubmissionKeyRef.current = null; + const pending = pendingDraftSubmissionRef.current; + if ( + pending?.key === key && + (draft.text !== pending.text || draft.skillName !== pending.skillName) + ) { + pendingDraftSubmissionRef.current = null; } + const reconciled = reconcileConversationReferences( + draft.text, + conversationReferences, + conversationCapability, + threadId, + ); + draft = { + ...draft, + text: reconciled.text, + conversationReferences: reconciled.references, + }; latestDraftRef.current = { key, draft }; cancelDraftSaveTimer(); draftSaveGenerationRef.current += 1; @@ -800,7 +947,13 @@ export function InputBox({ draftSaveTimerRef.current = timer; return timer; }, - [cancelDraftSaveTimer, draftKey], + [ + cancelDraftSaveTimer, + draftKey, + conversationReferences, + conversationCapability, + threadId, + ], ); const flushLatestDraft = useCallback( (expectedKey?: string) => { @@ -848,12 +1001,23 @@ export function InputBox({ promptHistoryDraftRef.current = ""; setTextInput(""); setSelectedSlashSkill(null); + setConversationReferences([]); + setMentionQuery(null); + setMentionButtonOpen(false); + setMentionError(null); + setMentionBusy(false); + mentionInFlight.current = false; + mentionEpoch.current += 1; setInputPolishUndo(null); setHydratedDraftKey(null); - pendingDraftSubmissionKeyRef.current = null; + pendingDraftSubmissionRef.current = null; + acceptedDraftRef.current = null; latestDraftRef.current = null; invalidateDraftSaveTimer(); - return () => flushLatestDraft(draftKey); + return () => { + mentionEpoch.current += 1; + flushLatestDraft(draftKey); + }; }, [draftKey, flushLatestDraft, invalidateDraftSaveTimer, setTextInput]); useLayoutEffect(() => { @@ -880,7 +1044,22 @@ export function InputBox({ } const resolvedDraft = resolveComposerDraft(savedDraft, enabledSkillNames); - setTextInput(resolvedDraft.text); + setConversationReferences(savedDraft.conversationReferences ?? []); + const existingReferences = inlineReferences(resolvedDraft.text); + const legacyReferences = (savedDraft.conversationReferences ?? []).filter( + (ref) => + !existingReferences.some( + (item) => item.kind === "conversation" && item.id === ref.threadId, + ), + ); + setTextInput( + legacyReferences + .map( + (ref) => + referenceToken("conversation", ref.threadId, ref.title) + " ", + ) + .join("") + resolvedDraft.text, + ); const restoredSkill = resolvedDraft.skillName ? agentScopedSkills.find( (skill) => skill.enabled && skill.name === resolvedDraft.skillName, @@ -1230,26 +1409,76 @@ export function InputBox({ const quotes = sidecar?.conversationQuotes ?? []; const quoteIds = quotes.map((quote) => quote.id); const quoteContexts = quotes.map((quote) => quote.context); - pendingDraftSubmissionKeyRef.current = draftKey; - const referenceIds = conversationReferences.map( + const currentReferences = inlineReferences(textInput.value); + if ( + (!conversationCapability.isSuccess || + conversationCapability.isLoading) && + currentReferences.some((ref) => ref.kind === "conversation") + ) { + return Promise.reject( + new Error( + "Conversation capability is not available. Retry discovery before sending.", + ), + ); + } + const activeConversations = reconcileConversationReferences( + textInput.value, + conversationReferences, + conversationCapability, + threadId, + ).references; + const referenceIds = activeConversations.map( (reference) => reference.threadId, ); // Project-shelf attachments are already ingested thread-side (§9): // they join ``additional_kwargs.files`` as completed uploads without a // re-upload, and merge with any files uploaded in this send // (buildThreadSubmitMessages concatenates the two lists). - const stagedFiles: FileInMessage[] = projectAttachments.map( - (attachment) => ({ + const stagedFiles: FileInMessage[] = projectAttachments + .filter( + (file) => + !file.source_document_id || + currentReferences.some( + (ref) => + ref.kind === "file" && ref.id === file.source_document_id, + ), + ) + .map((attachment) => ({ filename: attachment.filename, size: attachment.size_bytes, path: attachment.virtual_path, status: "uploaded" as const, - }), - ); + })); + const skillReferences = [ + ...new Set( + inlineReferences(textInput.value) + .filter((ref) => ref.kind === "skill") + .map((ref) => ref.id), + ), + ]; + if ( + skillReferences.length && + selectedSlashSkill?.kind === "skill" && + !skillReferences.includes(selectedSlashSkill.name) + ) + skillReferences.unshift(selectedSlashSkill.name); + if (skillReferences.length > MAX_EXPLICIT_SKILLS) { + toast.warning(t.inputBox.mentionMultipleSkills); + return Promise.reject(new Error("Too many skill references.")); + } + pendingDraftSubmissionRef.current = { + key: draftKey, + text: textInput.value, + skillName: + selectedSlashSkill?.kind === "skill" ? selectedSlashSkill.name : null, + }; const additionalKwargs = { + ...(skillReferences.length + ? { skill_references: skillReferences } + : {}), ...(quotes.length ? buildReferenceMessageMetadata(quoteContexts) : {}), ...(referenceIds.length - ? buildConversationReferenceMetadata(conversationReferences) + ? buildConversationReferenceMetadata(activeConversations) : {}), ...(stagedFiles.length > 0 ? { files: stagedFiles } : {}), }; @@ -1270,8 +1499,18 @@ export function InputBox({ // Clear one-time state only once the send genuinely proceeds. If the // send is dropped by the in-flight guard, `onSent` never fires. onSent: () => { - if (pendingDraftSubmissionKeyRef.current === draftKey) { - pendingDraftSubmissionKeyRef.current = null; + setMentionQuery(null); + setMentionButtonOpen(false); + if (pendingDraftSubmissionRef.current?.key === draftKey) { + acceptedDraftRef.current = { + key: draftKey, + text: textInput.value, + skillName: + selectedSlashSkill?.kind === "skill" + ? selectedSlashSkill.name + : null, + }; + pendingDraftSubmissionRef.current = null; latestDraftRef.current = null; invalidateDraftSaveTimer(); clearComposerDraft(getSessionComposerDraftStorage(), draftKey); @@ -1305,6 +1544,8 @@ export function InputBox({ }, [ context, + textInput.value, + selectedSlashSkill, conversationReferences, draftKey, invalidateDraftSaveTimer, @@ -1317,6 +1558,9 @@ export function InputBox({ selectedModel, sidecar, t.inputBox.suggestionPlaceholderRequired, + t.inputBox.mentionMultipleSkills, + conversationCapability, + threadId, uploadLimits, ], ); @@ -1341,11 +1585,14 @@ export function InputBox({ const handleSubmit = useCallback( async (message: PromptInputMessage) => { + if (mentionInFlight.current) + return Promise.reject(new Error("Reference is loading")); if (status === "streaming") { toast.info(t.inputBox.pleaseWaitStreaming); return Promise.reject(new Error("streaming")); } abortVoiceInput(); + message = { ...message, text: readableReferences(message.text) }; const messageWithSlashSkill = selectedSlashSkill ? { ...message, @@ -1358,9 +1605,12 @@ export function InputBox({ // files: submitThreadMessage maps them into the outgoing message's // ``additional_kwargs.files``, so an attachment-only submit must not // read as empty, and /goal or /compact must not intercept while an - // attach chip is present. + // attach chip or explicit conversation reference is present. fileCount: - messageWithSlashSkill.files.length + projectAttachments.length, + messageWithSlashSkill.files.length + + projectAttachments.length + + conversationReferences.length + + inlineRefs.length, status, }); // Check run-starting actions before goal preparation or persistence: @@ -1451,6 +1701,8 @@ export function InputBox({ handleGoalCommand, onPrepareThread, projectAttachments.length, + conversationReferences.length, + inlineRefs.length, selectedSlashSkill, status, submitThreadMessage, @@ -1534,12 +1786,13 @@ export function InputBox({ // rather than withholding them from the helper, which needs the list to // reserve their names — a skill named after a builtin is unusable for the // mirrored reason, its submitted text runs the command, not the skill. - return selectedSlashSkill + return selectedSlashSkill || hasInlineReferences ? matches.filter(({ kind }) => kind === "skill") : matches; }, [ agentScopedSkills, builtinSlashCommands, + hasInlineReferences, selectedSlashSkill, slashSkillQuery, ]); @@ -1553,7 +1806,283 @@ export function InputBox({ dismissedSkillSuggestionValue !== textInput.value; const isComposerDisabled = disabled === true; const isMockThread = isMock === true; - const composerLocked = isComposerDisabled || polishingInput; + const composerLocked = isComposerDisabled || polishingInput || mentionBusy; + const showMentions = + !disabled && + !polishingInput && + (mentionButtonOpen || mentionQuery !== null); + useLayoutEffect(() => { + if (!showMentions) return; + const place = () => { + const box = promptRootRef.current?.getBoundingClientRect(); + if (!box) return; + const top = box.top - 12; + const bottom = window.innerHeight - box.bottom - 12; + const above = top >= bottom; + setMentionPlacement({ + above, + maxHeight: Math.max(100, above ? top : bottom), + }); + }; + place(); + window.addEventListener("resize", place); + window.addEventListener("scroll", place, true); + return () => { + window.removeEventListener("resize", place); + window.removeEventListener("scroll", place, true); + }; + }, [showMentions]); + const closeMentions = useCallback(() => { + if (mentionInFlight.current) return; + dismissedMention.current = `${textInput.value}:${mentionQuery?.end}`; + setMentionQuery(null); + setMentionButtonOpen(false); + setMentionError(null); + }, [textInput.value, mentionQuery]); + const openMentions = useCallback(() => { + dismissedMention.current = null; + const caret = inlineSkillTextRef.current + ? referenceCaret(inlineSkillTextRef.current) + : textareaRef.current?.selectionStart; + if (caret != null) composerCaretRef.current = caret; + setMentionQuery(null); + setMentionButtonOpen(true); + setMentionError(null); + }, []); + const updateMentionQuery = useCallback( + (value: string, caret: number | null) => { + if (caret !== null) composerCaretRef.current = caret; + if (dismissedMention.current === `${value}:${caret}`) return; + dismissedMention.current = null; + setMentionButtonOpen(false); + setMentionError(null); + setMentionQuery(caret === null ? null : getMentionQuery(value, caret)); + }, + [], + ); + const focusComposerAt = useCallback((offset: number) => { + requestAnimationFrame(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.focus(); + textarea.setSelectionRange(offset, offset); + } else if (inlineSkillTextRef.current) + focusReferenceAt(inlineSkillTextRef.current, offset); + }); + }, []); + const selectMention = useCallback( + async (selection: MentionSelection) => { + if (mentionInFlight.current || disabled || polishingInput) return; + const epoch = mentionEpoch.current; + const originalText = textInput.value; + const caret = + mentionQuery?.start ?? + Math.min(composerCaretRef.current, originalText.length); + const selectionId = + selection.kind === "skill" + ? selection.skill.name + : selection.kind === "conversation" + ? selection.reference.threadId + : null; + const selected = + selectionId && + (inlineReferences(originalText).some( + (ref) => ref.kind === selection.kind && ref.id === selectionId, + ) || + (selection.kind === "skill" && + selectedSlashSkill?.name === selectionId)); + if (selected) { + let next = mentionQuery + ? originalText.slice(0, mentionQuery.start) + + originalText.slice(mentionQuery.end) + : originalText; + for (const ref of inlineReferences(next).reverse()) { + if (ref.kind === selection.kind && ref.id === selectionId) + next = next.slice(0, ref.start) + next.slice(ref.end); + } + if ( + selection.kind === "skill" && + selectedSlashSkill?.name === selectionId + ) + setSelectedSlashSkill(null); + if (selection.kind === "conversation") + setConversationReferences((previous) => + previous.filter((ref) => ref.threadId !== selectionId), + ); + textInput.setInput(next); + setMentionQuery(null); + setMentionButtonOpen(false); + focusComposerAt(Math.min(caret, next.length)); + return; + } + let token = ""; + if (selection.kind === "skill") + token = referenceToken( + "skill", + selection.skill.name, + selection.skill.name, + ); + if (selection.kind === "conversation") + token = referenceToken( + "conversation", + selection.reference.threadId, + selection.reference.title, + ); + if (selection.kind === "file") + token = referenceToken( + "file", + selection.document.id, + selection.document.name, + ); + const inserted = token ? token + " " : ""; + const nextText = + originalText.slice(0, caret) + + inserted + + originalText.slice(mentionQuery?.end ?? caret); + if (selection.kind === "file") { + if (!projectId) return; + const duplicate = projectAttachments.some( + (file) => + file.source_document_id === selection.document.id && + file.source_project_id === projectId, + ); + if (!duplicate) { + const allSizes = [ + ...projectAttachments.map((file) => file.size_bytes), + ...attachments.files.map((file) => file.file?.size ?? 0), + selection.document.size_bytes, + ]; + if ( + uploadLimits && + (allSizes.length > uploadLimits.max_files || + allSizes.reduce((a, b) => a + b, 0) > + uploadLimits.max_total_size || + selection.document.size_bytes > uploadLimits.max_file_size) + ) { + setMentionError( + t.uploads.limitsHint( + uploadLimits.max_files, + formatUploadSize(uploadLimits.max_file_size), + formatUploadSize(uploadLimits.max_total_size), + ), + ); + return; + } + mentionInFlight.current = true; + setMentionBusy(true); + setMentionError(null); + try { + await onPrepareThread?.(); + if (epoch !== mentionEpoch.current) return; + const attached = await attachProjectDocument( + projectId, + selection.document.id, + threadId, + ); + if (epoch !== mentionEpoch.current) return; + setProjectAttachments((previous) => [ + ...previous.filter( + (file) => file.virtual_path !== attached.virtual_path, + ), + { + ...attached, + source_document_id: selection.document.id, + source_project_id: projectId, + }, + ]); + } catch { + if (epoch === mentionEpoch.current) + setMentionError(t.inputBox.mentionAttachFailed); + return; + } finally { + if (epoch === mentionEpoch.current) { + mentionInFlight.current = false; + setMentionBusy(false); + } + } + } + } else if (selection.kind === "conversation") { + setConversationReferences((previous) => + previous.some( + (item) => item.threadId === selection.reference.threadId, + ) + ? previous + : [...previous, selection.reference], + ); + } else if (selection.kind === "upload") { + // Keep the native file dialog in the user gesture for browser permission. + attachments.openFileDialog(); + } + if ( + selection.kind === "file" && + onReferenceFileAttached && + draftThreadId !== threadId + ) { + // A prepared new conversation now owns real files. Move its draft to + // the durable identity before the page replaces /new with that ID. + const storage = getSessionComposerDraftStorage(); + const nextKey = buildComposerDraftKey({ + userId: user?.id ?? "anonymous", + agentName: + draftAgentName ?? + (typeof context.agent_name === "string" + ? context.agent_name + : null), + threadId, + }); + writeComposerDraft(storage, nextKey, { + text: nextText, + skillName: selectedSlashSkill?.name ?? null, + ...(conversationReferences.length ? { conversationReferences } : {}), + }); + invalidateDraftSaveTimer(); + latestDraftRef.current = null; + clearComposerDraft(storage, draftKey); + } + textInput.setInput(nextText); + setMentionQuery(null); + setMentionButtonOpen(false); + setMentionError(null); + focusComposerAt(caret + inserted.length); + if (selection.kind === "file") onReferenceFileAttached?.(); + }, + [ + attachments, + context.agent_name, + conversationReferences, + draftAgentName, + draftKey, + draftThreadId, + invalidateDraftSaveTimer, + onReferenceFileAttached, + selectedSlashSkill, + user?.id, + disabled, + focusComposerAt, + mentionQuery, + onPrepareThread, + polishingInput, + projectAttachments, + projectId, + setProjectAttachments, + t, + textInput, + threadId, + uploadLimits, + ], + ); + useEffect(() => { + if (!showMentions) return; + const dismiss = (event: PointerEvent) => { + if ( + event.target instanceof Node && + !promptRootRef.current?.contains(event.target) + ) + closeMentions(); + }; + document.addEventListener("pointerdown", dismiss); + return () => document.removeEventListener("pointerdown", dismiss); + }, [showMentions, closeMentions]); // A denied runs:cancel role sees a disabled stop affordance, not a removed // one — the composer must still show that a turn is in flight. const stopDenied = status === "streaming" && !canStopStreaming; @@ -1675,7 +2204,7 @@ export function InputBox({ recognition.start(); if (options.focusAfterStart) { requestAnimationFrame(() => { - if (selectedSlashSkill) { + if (inlineSkillTextRef.current) { focusContentEditableEnd(inlineSkillTextRef.current); } else { textareaRef.current?.focus(); @@ -1694,7 +2223,6 @@ export function InputBox({ composerLocked, getVoiceInputErrorMessage, locale, - selectedSlashSkill, speechRecognitionConstructor, t.inputBox.voiceInputFailed, textInput, @@ -1845,12 +2373,13 @@ export function InputBox({ (value: string) => { textInput.setInput(value); requestAnimationFrame(() => { - const textarea = textareaRef.current; - if (!textarea) { - return; + if (inlineSkillTextRef.current) + focusReferenceAt(inlineSkillTextRef.current, value.length); + else { + const textarea = textareaRef.current; + textarea?.focus(); + textarea?.setSelectionRange(value.length, value.length); } - textarea.focus(); - textarea.setSelectionRange(value.length, value.length); }); }, [textInput], @@ -1874,7 +2403,7 @@ export function InputBox({ try { const result = await polishInputDraft( { - text: originalText, + text: readableReferences(originalText), locale, thread_id: threadId, }, @@ -1889,7 +2418,10 @@ export function InputBox({ return; } - const rewrittenText = result.rewritten_text.trim(); + const rewrittenText = restoreReferenceLabels( + originalText, + result.rewritten_text.trim(), + ); if (!rewrittenText || !result.changed) { toast.info(t.inputBox.inputPolishNoChanges); return; @@ -2027,6 +2559,8 @@ export function InputBox({ // carries neither composition flag; PromptInputTextarea drops it before // this handler runs. if (!isIMEComposing(event)) { + if (showMentions) mentionPickerRef.current?.onKeyDown(event); + if (event.defaultPrevented) return; handleSkillSuggestionKeyDown(event); if (event.defaultPrevented) { return; @@ -2039,6 +2573,7 @@ export function InputBox({ handlePromptHistoryKeyDown(event); }, [ + showMentions, handlePromptHistoryKeyDown, handleSelectedSlashSkillKeyDown, handleSkillSuggestionKeyDown, @@ -2047,6 +2582,12 @@ export function InputBox({ const handlePromptTextareaChange = useCallback( (event: ChangeEvent) => { + acceptedDraftRef.current = null; + pendingDraftSubmissionRef.current = null; + updateMentionQuery( + event.currentTarget.value, + event.currentTarget.selectionStart, + ); if (voiceListening) { abortVoiceInput(); } @@ -2065,19 +2606,49 @@ export function InputBox({ abortVoiceInput, scheduleDraftSave, selectedSlashSkill, + updateMentionQuery, voiceListening, ], ); const updateInlineSkillTextInput = useCallback( (element: HTMLElement) => { + acceptedDraftRef.current = null; + pendingDraftSubmissionRef.current = null; if (voiceListening) { abortVoiceInput(); } promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; - const nextText = element.textContent ?? ""; + const nextText = readReferenceEditor(element); + const refs = inlineReferences(nextText); + setConversationReferences( + (previous) => + reconcileConversationReferences( + nextText, + previous, + conversationCapability, + threadId, + ).references, + ); + setProjectAttachments((previous) => { + const ids = new Set( + refs.filter((ref) => ref.kind === "file").map((ref) => ref.id), + ); + return [ + ...previous.filter((file) => !file.source_document_id), + ...[...ids].flatMap((id) => { + const file = projectReferenceCache.current.get(id); + return file ? [file] : []; + }), + ]; + }); + abortInputPolishRequest(); + setInputPolishUndo(null); + const caret = referenceCaret(element); textInput.setInput(nextText); + updateMentionQuery(nextText, caret); + if (!refs.length && caret !== null) focusComposerAt(caret); scheduleDraftSave({ text: nextText, skillName: @@ -2085,24 +2656,26 @@ export function InputBox({ }); }, [ + conversationCapability, + threadId, abortVoiceInput, + abortInputPolishRequest, + setProjectAttachments, + focusComposerAt, scheduleDraftSave, selectedSlashSkill, textInput, + updateMentionQuery, voiceListening, ], ); - useEffect(() => { - if (!selectedSlashSkill) { - return; - } - + useLayoutEffect(() => { + if (hasInlineReferences) setInlineEditorActive(true); const element = inlineSkillTextRef.current; - if (element && element.textContent !== textInput.value) { - element.textContent = textInput.value; - } - }, [selectedSlashSkill, textInput.value]); + if (element && !inlineSkillComposingRef.current) + renderReferenceEditor(element, textInput.value); + }, [hasInlineReferences, selectedSlashSkill, textInput.value]); const handleInlineSkillInput = useCallback( (event: FormEvent) => { @@ -2147,10 +2720,16 @@ export function InputBox({ const handleInlineSkillKeyDown = useCallback( (event: KeyboardEvent) => { + if (isCompositionConfirmEnter(event, inlineCompositionEndedAt.current)) { + event.preventDefault(); + return; + } // The catalog can be reopened from here, so its navigation keys must win // over Enter-to-submit. Skip it mid-composition, where Enter belongs to // the IME candidate rather than the list. if (!isIMEComposing(event, inlineSkillComposingRef.current)) { + if (showMentions) mentionPickerRef.current?.onKeyDown(event); + if (event.defaultPrevented) return; handleSkillSuggestionKeyDown(event); if (event.defaultPrevented) { return; @@ -2162,9 +2741,8 @@ export function InputBox({ return; } - if (event.key !== "Enter") { - return; - } + handlePromptHistoryKeyDown(event); + if (event.defaultPrevented || event.key !== "Enter") return; if (isIMEComposing(event, inlineSkillComposingRef.current)) { return; @@ -2182,6 +2760,8 @@ export function InputBox({ event.currentTarget.closest("form")?.requestSubmit(); }, [ + showMentions, + handlePromptHistoryKeyDown, handleSelectedSlashSkillKeyDown, handleSkillSuggestionKeyDown, updateInlineSkillTextInput, @@ -2189,6 +2769,7 @@ export function InputBox({ ); const clearSelectedSlashSkill = useCallback(() => { + if (mentionInFlight.current) return; setSelectedSlashSkill(null); requestAnimationFrame(() => { textareaRef.current?.focus(); @@ -2199,6 +2780,7 @@ export function InputBox({ !disabled && !isWelcomeMode && !showSkillSuggestions && + !showMentions && !selectedSlashSkill && !followupsHidden && // Never show stale follow-up chips while a turn is streaming: a message @@ -2369,7 +2951,51 @@ export function InputBox({ )} - {showSkillSuggestions && ( + {showMentions && ( +
+ + getMatchingSkillSuggestions( + [skill], + "", + builtinSlashCommands, + ).some((item) => item.kind === "skill"), + )} + selectedSkills={[ + ...inlineRefs + .filter((ref) => ref.kind === "skill") + .map((ref) => ref.id), + ...(selectedSlashSkill ? [selectedSlashSkill.name] : []), + ]} + references={reconciledConversations.references} + capability={conversationCapability} + skillsLoading={skillsLoading || skillsFetching} + skillsError={skillsError} + onRetrySkills={refetchSkills} + onActiveOptionChange={setActiveMentionOption} + threadId={threadId} + projectId={projectId} + busy={mentionBusy} + error={mentionError} + onSelect={(selection) => void selectMention(selection)} + onClose={() => { + closeMentions(); + focusComposerAt(mentionQuery?.end ?? textInput.value.length); + }} + /> +
+ )} + {showSkillSuggestions && !showMentions && (
)} + {!!conversationCapability.error && + inlineRefs.some((ref) => ref.kind === "conversation") && ( +
+ {t.inputBox.mentionFailed}{" "} + +
+ )} {(attachment) => (
@@ -2449,47 +3089,33 @@ export function InputBox({
)}
- {projectAttachments.map((attachment) => ( -
- - {attachment.filename} - -
- ))} - {conversationReferences.map((reference) => ( - - setConversationReferences((current) => - current.filter( - (item) => item.threadId !== reference.threadId, - ), - ) - } - removeLabel={t.inputBox.referenceConversationsRemove( - reference.title, - )} - title={reference.title} - /> - ))} + + {attachment.filename} + +
+ ))} {polishingInput && (
- {selectedSlashSkill ? ( + {hasInlineReferences || inlineEditorActive || selectedSlashSkill ? (
{ if (event.target === event.currentTarget) { focusContentEditableEnd(inlineSkillTextRef.current); } }} > - + {selectedSlashSkill && ( + + )} setTextareaFocused(false)} onCompositionEnd={() => { inlineSkillComposingRef.current = false; + inlineCompositionEndedAt.current = Date.now(); }} onCompositionStart={() => { inlineSkillComposingRef.current = true; }} onFocus={() => setTextareaFocused(true)} + aria-controls={showMentions ? mentionListId : undefined} + aria-activedescendant={ + showMentions ? activeMentionOption : undefined + } + onClick={(event) => + updateMentionQuery( + readReferenceEditor(event.currentTarget), + referenceCaret(event.currentTarget), + ) + } + onKeyUp={(event) => { + if ( + ["ArrowLeft", "ArrowRight", "Home", "End"].includes( + event.key, + ) + ) + updateMentionQuery( + readReferenceEditor(event.currentTarget), + referenceCaret(event.currentTarget), + ); + }} onInput={handleInlineSkillInput} onKeyDown={handleInlineSkillKeyDown} onPaste={handleInlineSkillPaste} @@ -2555,7 +3207,9 @@ export function InputBox({ role="textbox" suppressContentEditableWarning className={cn( - "outline-none", + selectedSlashSkill + ? "inline min-h-6 outline-none" + : "block min-h-6 outline-none", "before:text-muted-foreground before:pointer-events-none", "data-[empty=true]:before:content-[attr(data-placeholder)]", composerLocked && "cursor-not-allowed opacity-50", @@ -2571,6 +3225,20 @@ export function InputBox({ autoFocus={autoFocus} defaultValue={initialValue} onBlur={() => setTextareaFocused(false)} + aria-controls={showMentions ? mentionListId : undefined} + aria-activedescendant={ + showMentions ? activeMentionOption : undefined + } + onSelect={(event) => { + const input = event.currentTarget; + if (!mentionButtonOpen && !mentionInFlight.current) + updateMentionQuery( + input.value, + input.selectionStart === input.selectionEnd + ? input.selectionStart + : null, + ); + }} onChange={handlePromptTextareaChange} onFocus={() => setTextareaFocused(true)} onKeyDown={handlePromptTextareaKeyDown} @@ -2580,18 +3248,22 @@ export function InputBox({
+ + + + + -
@@ -3088,7 +3766,9 @@ function AddAttachmentsButton({ className, disabled, uploadLimits, + onOpen, }: { + onOpen?: () => void; className?: string; disabled?: boolean; uploadLimits?: UploadLimits; @@ -3109,7 +3789,7 @@ function AddAttachmentsButton({ className={cn("px-2!", className)} data-testid="add-attachments-button" disabled={disabled} - onClick={() => attachments.openFileDialog()} + onClick={onOpen ?? (() => attachments.openFileDialog())} > diff --git a/frontend/src/components/workspace/mentions/inline-references.ts b/frontend/src/components/workspace/mentions/inline-references.ts new file mode 100644 index 0000000000..d0a96cd9cd --- /dev/null +++ b/frontend/src/components/workspace/mentions/inline-references.ts @@ -0,0 +1,210 @@ +import type { ConversationReference } from "@/core/conversation-references"; + +export const MAX_EXPLICIT_SKILLS = 16; + +export type InlineReference = { + kind: "skill" | "file" | "conversation"; + id: string; + label: string; + start: number; + end: number; +}; + +const pattern = /@\[([^\]]*)\]\(ref:(skill|file|conversation):([^)]*)\)/g; + +export function referenceToken( + kind: InlineReference["kind"], + id: string, + label: string, +) { + const encode = (value: string) => + encodeURIComponent(value).replace( + /[!'()*]/g, + (char) => `%${char.charCodeAt(0).toString(16)}`, + ); + return `@[${encode(label)}](ref:${kind}:${encode(id)})`; +} + +export function inlineReferences(text: string): InlineReference[] { + const result: InlineReference[] = []; + for (const match of text.matchAll(pattern)) { + try { + result.push({ + kind: match[2] as InlineReference["kind"], + id: decodeURIComponent(match[3]!), + label: decodeURIComponent(match[1]!), + start: match.index, + end: match.index + match[0].length, + }); + } catch { + /* Malformed pasted text remains ordinary text. */ + } + } + return result; +} + +export function readableReferences(text: string) { + let value = text; + for (const reference of inlineReferences(text).reverse()) { + value = + value.slice(0, reference.start) + + `@${reference.label}` + + value.slice(reference.end); + } + return value; +} + +/** Restore display labels in one pass so replacement tokens cannot match again. */ +export function restoreReferenceLabels(original: string, rewritten: string) { + const remaining = inlineReferences(original); + if (!remaining.length) return rewritten; + const labels = [...new Set(remaining.map((ref) => ref.label))] + .sort((a, b) => b.length - a.length) + .map((label) => label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + // Longest labels win; a short label cannot consume a longer word or slug. + const pattern = new RegExp( + `@(?:${labels.join("|")})(?![\\p{L}\\p{M}\\p{N}_-])`, + "gu", + ); + const restored = rewritten.replace(pattern, (label) => { + const index = remaining.findIndex((ref) => `@${ref.label}` === label); + if (index < 0) return label; + const [ref] = remaining.splice(index, 1); + return referenceToken(ref!.kind, ref!.id, ref!.label); + }); + // A rewrite may omit a label; retain that explicit selection in draft order. + return [ + ...remaining.map((ref) => referenceToken(ref.kind, ref.id, ref.label)), + restored, + ].join(" "); +} + +export function readReferenceEditor(node: Node): string { + if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? ""; + if (node instanceof HTMLElement) { + if (node.dataset.reference) return node.dataset.reference; + if (node.tagName === "BR") return "\n"; + } + return Array.from(node.childNodes, readReferenceEditor).join(""); +} + +export function referenceCaret(root: HTMLElement): number | null { + const selection = window.getSelection(); + if (!selection?.isCollapsed || !selection.rangeCount) return null; + const range = selection.getRangeAt(0); + if (!root.contains(range.endContainer)) return null; + const before = range.cloneRange(); + before.selectNodeContents(root); + before.setEnd(range.endContainer, range.endOffset); + return readReferenceEditor(before.cloneContents()).length; +} + +export function focusReferenceAt(root: HTMLElement, offset: number) { + root.focus(); + const range = document.createRange(); + range.selectNodeContents(root); + range.collapse(false); + let remaining = offset; + for (const node of root.childNodes) { + const length = readReferenceEditor(node).length; + if (remaining <= length) { + if (node.nodeType === Node.TEXT_NODE) range.setStart(node, remaining); + else if (remaining === 0) range.setStartBefore(node); + else range.setStartAfter(node); + range.collapse(true); + break; + } + remaining -= length; + } + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); +} + +export function renderReferenceEditor(root: HTMLElement, text: string) { + if (readReferenceEditor(root) === text) return; + const caret = referenceCaret(root); + const fragment = document.createDocumentFragment(); + let end = 0; + for (const ref of inlineReferences(text)) { + fragment.append(document.createTextNode(text.slice(end, ref.start))); + const token = document.createElement("span"); + token.contentEditable = "false"; + token.dataset.reference = text.slice(ref.start, ref.end); + token.dataset.referenceKind = ref.kind; + token.dataset.testid = + ref.kind === "file" + ? "project-attachment-chip" + : ref.kind === "conversation" + ? "conversation-reference-chip" + : "inline-skill-reference"; + token.className = + "inline-flex items-baseline gap-1 align-baseline font-medium select-all " + + (ref.kind === "skill" + ? "text-blue-600 dark:text-blue-400" + : ref.kind === "file" + ? "text-rose-600 dark:text-rose-400" + : "text-violet-600 dark:text-violet-400"); + const icon = document.createElement("span"); + icon.setAttribute("aria-hidden", "true"); + icon.textContent = + ref.kind === "skill" ? "✦" : ref.kind === "file" ? "▤" : "◉"; + token.append(icon, document.createTextNode(ref.label)); + token.setAttribute("aria-label", `@${ref.label}`); + fragment.append(token); + end = ref.end; + } + fragment.append(document.createTextNode(text.slice(end))); + root.replaceChildren(fragment); + if (caret !== null) focusReferenceAt(root, Math.min(caret, text.length)); +} + +export function reconcileConversationReferences( + text: string, + known: ConversationReference[], + capability: { + enabled: boolean; + maxReferences: number; + isLoading: boolean; + isSuccess: boolean; + }, + threadId: string, +): { text: string; references: ConversationReference[] } { + // Only a successful discovery may destructively normalize the draft. + // Preserve known metadata while pending/failed, including across editor changes. + if (!capability.isSuccess || capability.isLoading) { + const ids = new Set( + inlineReferences(text) + .filter((ref) => ref.kind === "conversation") + .map((ref) => ref.id), + ); + return { text, references: known.filter((ref) => ids.has(ref.threadId)) }; + } + const references = new Map(); + const limit = capability.enabled ? capability.maxReferences : 0; + const tokens = inlineReferences(text).filter( + (ref) => ref.kind === "conversation", + ); + for (const ref of tokens) { + if ( + !ref.id || + ref.id === threadId || + references.has(ref.id) || + references.size >= limit + ) + continue; + references.set( + ref.id, + known.find((item) => item.threadId === ref.id) ?? { + threadId: ref.id, + title: ref.label, + }, + ); + } + // Disabled, self, and over-limit references become honest ordinary text. + for (const ref of tokens.reverse()) { + if (!references.has(ref.id)) + text = text.slice(0, ref.start) + `@${ref.label}` + text.slice(ref.end); + } + return { text, references: [...references.values()] }; +} diff --git a/frontend/src/components/workspace/mentions/mention-picker.tsx b/frontend/src/components/workspace/mentions/mention-picker.tsx new file mode 100644 index 0000000000..899dfffcf3 --- /dev/null +++ b/frontend/src/components/workspace/mentions/mention-picker.tsx @@ -0,0 +1,420 @@ +"use client"; + +import { + CheckIcon, + FileIcon, + MessagesSquareIcon, + SparklesIcon, + UploadIcon, + XIcon, +} from "lucide-react"; +import { + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + type KeyboardEvent, + type Ref, +} from "react"; + +import type { ConversationReference } from "@/core/conversation-references"; +import { useI18n } from "@/core/i18n/hooks"; +import { useInfiniteProjectDocuments } from "@/core/projects/hooks"; +import type { ProjectDocument } from "@/core/projects/types"; +import type { Skill } from "@/core/skills/type"; +import { useInfiniteThreads } from "@/core/threads/hooks"; +import { agentNameOfThread, titleOfThread } from "@/core/threads/utils"; +import { isCompositionConfirmEnter, isIMEComposing } from "@/lib/ime"; +import { cn } from "@/lib/utils"; + +import { MAX_EXPLICIT_SKILLS } from "./inline-references"; + +export type MentionSelection = + | { kind: "skill"; skill: Skill } + | { kind: "file"; document: ProjectDocument } + | { kind: "conversation"; reference: ConversationReference } + | { kind: "upload" }; +export type MentionPickerHandle = { + onKeyDown: (event: KeyboardEvent) => void; +}; +type Option = { + id: string; + label: string; + description?: string; + selection: MentionSelection; + disabled?: boolean; + selected?: boolean; +}; + +export function MentionPicker({ + ref, + query, + searchInput = false, + skills, + skillsLoading = false, + skillsError, + onRetrySkills, + capability, + onActiveOptionChange, + selectedSkills, + references, + threadId, + projectId, + busy, + error, + onSelect, + onClose, + listId, +}: { + ref?: Ref; + query: string; + searchInput?: boolean; + skills: Skill[]; + skillsLoading?: boolean; + skillsError?: unknown; + onRetrySkills?: () => unknown; + capability: { + enabled: boolean; + maxReferences: number; + isLoading: boolean; + error?: unknown; + refetch?: () => unknown; + }; + onActiveOptionChange?: (id: string | undefined) => void; + selectedSkills?: string[]; + references: ConversationReference[]; + threadId: string; + projectId?: string | null; + busy: boolean; + error: string | null; + onSelect: (selection: MentionSelection) => void; + onClose: () => void; + listId: string; +}) { + const { t } = useI18n(); + const labels = t.inputBox; + const documents = useInfiniteProjectDocuments(projectId ?? "", { + enabled: !!projectId, + }); + const conversations = useInfiniteThreads(undefined, { + enabled: capability.enabled, + }); + const [search, setSearch] = useState(""); + const [activeId, setActiveId] = useState(null); + const rootRef = useRef(null); + const compositionEndedAt = useRef(-Infinity); + const filter = (searchInput ? search : query).toLocaleLowerCase(); + const options = useMemo(() => { + const result: Option[] = skills + .filter((skill) => skill.enabled) + .map((skill) => ({ + id: `skill:${skill.name}`, + label: skill.name, + description: skill.description, + selection: { kind: "skill", skill }, + selected: selectedSkills?.includes(skill.name), + disabled: + !selectedSkills?.includes(skill.name) && + new Set(selectedSkills).size >= MAX_EXPLICIT_SKILLS, + })); + for (const document of documents.data?.pages.flatMap( + (page) => page.documents, + ) ?? []) { + result.push({ + id: `file:${document.id}`, + label: document.name, + description: document.content_missing + ? labels.mentionUnavailable + : undefined, + selection: { kind: "file", document }, + disabled: document.content_missing, + }); + } + if (capability.enabled) { + const seen = new Set(); + for (const thread of conversations.data?.pages.flat() ?? []) { + if (thread.thread_id === threadId || seen.has(thread.thread_id)) + continue; + seen.add(thread.thread_id); + const selected = references.some( + (item) => item.threadId === thread.thread_id, + ); + result.push({ + id: `conversation:${thread.thread_id}`, + label: titleOfThread(thread), + selection: { + kind: "conversation", + reference: { + threadId: thread.thread_id, + title: titleOfThread(thread), + agentName: agentNameOfThread(thread), + }, + }, + selected, + disabled: !selected && references.length >= capability.maxReferences, + }); + } + } + const filtered = result.filter((option) => + `${option.label} ${option.description ?? ""}` + .toLocaleLowerCase() + .includes(filter), + ); + filtered.push({ + id: "upload", + label: labels.mentionUpload, + selection: { kind: "upload" }, + }); + return filtered; + }, [ + skills, + selectedSkills, + documents.data, + conversations.data, + capability.enabled, + capability.maxReferences, + threadId, + references, + filter, + labels, + ]); + const available = options.filter((option) => !option.disabled); + const active = + available.find((option) => option.id === activeId) ?? + (filter + ? available.find((option) => option.selection.kind !== "upload") + : available[0]); + const optionId = (id: string) => `${listId}-${encodeURIComponent(id)}`; + const activeOptionId = active ? optionId(active.id) : undefined; + useEffect(() => { + onActiveOptionChange?.(searchInput ? undefined : activeOptionId); + }, [activeOptionId, searchInput, onActiveOptionChange]); + useEffect( + () => () => onActiveOptionChange?.(undefined), + [onActiveOptionChange], + ); + const choose = (option: Option) => { + if (!busy && !option.disabled) onSelect(option.selection); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (isCompositionConfirmEnter(event, compositionEndedAt.current)) { + event.preventDefault(); + return; + } + if (isIMEComposing(event)) return; + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + return; + } + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + const index = available.findIndex((option) => option.id === active?.id); + const next = + available[ + (index + (event.key === "ArrowDown" ? 1 : -1) + available.length) % + available.length + ]; + setActiveId(next?.id ?? null); + } else if (event.key === "Enter" && !event.shiftKey && active) { + event.preventDefault(); + choose(active); + } + }; + useImperativeHandle(ref, () => ({ onKeyDown })); + useEffect(() => { + if (activeOptionId) + document + .getElementById(activeOptionId) + ?.scrollIntoView?.({ block: "nearest" }); + }, [activeOptionId]); + const loading = + skillsLoading || + capability.isLoading || + (!!projectId && documents.isPending) || + (capability.enabled && conversations.isPending); + const failed = + !!capability.error || + !!skillsError || + (!!projectId && documents.isError) || + (capability.enabled && conversations.isError); + const groups = [ + { kind: "skill", title: labels.mentionSkills, Icon: SparklesIcon }, + { kind: "file", title: labels.mentionFiles, Icon: FileIcon }, + { + kind: "conversation", + title: labels.mentionConversations, + Icon: MessagesSquareIcon, + }, + { kind: "upload", title: "", Icon: UploadIcon }, + ] as const; + return ( +
+
+ {searchInput ? ( + { + setSearch(event.target.value); + setActiveId(null); + }} + onKeyDown={onKeyDown} + onCompositionEnd={() => { + compositionEndedAt.current = Date.now(); + }} + /> + ) : ( + + {labels.mentionPicker} + + )} + +
+
+ {groups.map(({ kind, title, Icon }) => { + const items = options.filter( + (option) => option.selection.kind === kind, + ); + if (!items.length) return null; + return ( +
+ {title && ( +
+ {title} +
+ )} + {items.map((option) => ( + + ))} +
+ ); + })} +
+ {options.length === 1 && !loading && !failed && ( +

+ {labels.mentionEmpty} +

+ )} + {loading && ( +

+ {labels.mentionLoading} +

+ )} + {failed && ( +
+ {labels.mentionFailed}{" "} + +
+ )} + {(documents.hasNextPage || + (capability.enabled && conversations.hasNextPage)) && ( + + )} + {!projectId && ( +

+ {labels.mentionNoProject} +

+ )} +

+ {labels.mentionMultipleSkills} +

+ {capability.enabled && ( +

+ {labels.referenceConversationsLimit(capability.maxReferences)} +

+ )} + {busy && ( +

+ {labels.mentionAttaching} +

+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/frontend/src/components/workspace/mentions/query.ts b/frontend/src/components/workspace/mentions/query.ts new file mode 100644 index 0000000000..7079dc0241 --- /dev/null +++ b/frontend/src/components/workspace/mentions/query.ts @@ -0,0 +1,14 @@ +export type MentionQuery = { start: number; end: number; query: string }; + +/** Only a standalone @ at the caret opens the picker, never an email or URL. */ +export function getMentionQuery( + text: string, + caret: number, +): MentionQuery | null { + if (caret < 0 || caret > text.length) return null; + const before = text.slice(0, caret); + const match = /(?:^|[\s((,,。::])@([^\s@/\\]*)$/u.exec(before); + if (!match) return null; + const query = match[1] ?? ""; + return { start: caret - query.length - 1, end: caret, query }; +} diff --git a/frontend/src/components/workspace/slash-skill-chip.tsx b/frontend/src/components/workspace/slash-skill-chip.tsx index 61427d6a88..b93e4f3619 100644 --- a/frontend/src/components/workspace/slash-skill-chip.tsx +++ b/frontend/src/components/workspace/slash-skill-chip.tsx @@ -3,7 +3,7 @@ import { XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; /** - * Shared visual for a `/skill` activation, used both as a removable chip in the + * Shared visual for a `@skill` activation, used both as a removable chip in the * composer and as a read-only chip in the chat transcript. Keeping a single * source of truth means the two stay in lockstep instead of drifting apart in * their Tailwind classes. @@ -16,26 +16,29 @@ export function SlashSkillChip({ className, onRemove, removeLabel, + disabled = false, }: { name: string; className?: string; /** When provided, the chip renders as a removable button with a close icon. */ onRemove?: () => void; removeLabel?: string; + disabled?: boolean; }) { if (onRemove) { return ( ); @@ -43,7 +46,7 @@ export function SlashSkillChip({ return ( - /{name} + @{name} ); } diff --git a/frontend/src/core/features/hooks.ts b/frontend/src/core/features/hooks.ts index 7c6a3d3742..618fe799e0 100644 --- a/frontend/src/core/features/hooks.ts +++ b/frontend/src/core/features/hooks.ts @@ -55,7 +55,7 @@ export function useSubagentBatchesCapability() { } export function useConversationReferencesCapability() { - const { data, isPending } = useQuery({ + const { data, isPending, isSuccess, error, refetch } = useQuery({ queryKey: ["features", "conversation_references"], queryFn: () => fetchConversationReferencesCapability(), staleTime: 0, @@ -63,9 +63,12 @@ export function useConversationReferencesCapability() { retry: false, }); return { - enabled: data?.enabled ?? false, + enabled: isSuccess && (data?.enabled ?? false), maxReferences: data?.maxReferences ?? 0, isLoading: isPending, + isSuccess, + error, + refetch, }; } diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index e4f211ccac..1243679f76 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -347,6 +347,26 @@ export const enUS: Translations = { // Input Box inputBox: { + mentionPicker: "Add a reference", + mentionSearch: "Search skills, project files and conversations", + mentionSkills: "Skills", + mentionFiles: "Project files", + mentionConversations: "Conversations", + mentionUpload: "Upload a file", + mentionEmpty: "No matching references in loaded results", + mentionLoadMore: "Load more", + mentionLoading: "Loading references…", + mentionFailed: "Could not load references. Try again.", + mentionRetry: "Retry", + mentionAttaching: "Adding file…", + mentionAttachFailed: "Could not add this file. Try again.", + mentionMultipleSkills: + "Select up to 16 skills per message; select a checked skill again to remove it.", + mentionNoProject: "Open a project chat to reference its documents.", + mentionRemoveSkill: "Remove skill", + mentionUnavailable: "File unavailable", + mentionClose: "Close references", + placeholder: "How can I assist you today?", disclaimer: "DeerFlow is AI and can make mistakes", createSkillPrompt: diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index a8a67142e1..22e6813c26 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -301,6 +301,25 @@ export interface Translations { // Input Box inputBox: { + mentionPicker: string; + mentionSearch: string; + mentionSkills: string; + mentionFiles: string; + mentionConversations: string; + mentionUpload: string; + mentionEmpty: string; + mentionLoadMore: string; + mentionLoading: string; + mentionFailed: string; + mentionRetry: string; + mentionAttaching: string; + mentionAttachFailed: string; + mentionMultipleSkills: string; + mentionNoProject: string; + mentionRemoveSkill: string; + mentionUnavailable: string; + mentionClose: string; + placeholder: string; disclaimer: string; createSkillPrompt: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index bf072fcd15..b685b8e4e1 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -330,6 +330,26 @@ export const zhCN: Translations = { // Input Box inputBox: { + mentionPicker: "添加引用", + mentionSearch: "搜索技能、项目文件和对话", + mentionSkills: "技能", + mentionFiles: "项目文件", + mentionConversations: "对话", + mentionUpload: "上传文件", + mentionEmpty: "已加载的内容中没有匹配的引用", + mentionLoadMore: "加载更多", + mentionLoading: "正在加载引用…", + mentionFailed: "引用加载失败,请重试。", + mentionRetry: "重试", + mentionAttaching: "正在添加文件…", + mentionAttachFailed: "文件添加失败,请重试。", + mentionMultipleSkills: + "每条消息最多选择 16 个技能,再次选择已勾选技能可移除。", + mentionNoProject: "在项目对话中可引用该项目的文档。", + mentionRemoveSkill: "移除技能", + mentionUnavailable: "文件不可用", + mentionClose: "关闭引用面板", + placeholder: "今天我能为你做些什么?", disclaimer: "内容由AI生成,重要信息请务必核查", createSkillPrompt: diff --git a/frontend/src/core/projects/types.ts b/frontend/src/core/projects/types.ts index 10431a602e..04e8688e28 100644 --- a/frontend/src/core/projects/types.ts +++ b/frontend/src/core/projects/types.ts @@ -82,6 +82,9 @@ export type PromoteThreadFileInput = { * the ingested thread upload the composer adds to its attachment list. */ export type AttachProjectDocumentResult = { + /** Client draft provenance for deduplicating picker selections. */ + source_document_id?: string; + source_project_id?: string; filename: string; size_bytes: number; virtual_path: string; diff --git a/frontend/src/core/skills/hooks.ts b/frontend/src/core/skills/hooks.ts index 7b5121dc4e..a224427384 100644 --- a/frontend/src/core/skills/hooks.ts +++ b/frontend/src/core/skills/hooks.ts @@ -5,12 +5,12 @@ import { enableSkill, SkillRequestError, uploadSkillArchive } from "./api"; import { loadSkills } from "."; export function useSkills() { - const { data, isLoading, error } = useQuery({ + const { data, isLoading, isFetching, error, refetch } = useQuery({ queryKey: ["skills"], queryFn: () => loadSkills(), retry: (count, err) => !(err instanceof SkillRequestError) && count < 3, }); - return { skills: data ?? [], isLoading, error }; + return { skills: data ?? [], isLoading, isFetching, error, refetch }; } export function useEnableSkill() { diff --git a/frontend/src/core/threads/composer-draft.ts b/frontend/src/core/threads/composer-draft.ts index 1d71b9baa1..4382469cff 100644 --- a/frontend/src/core/threads/composer-draft.ts +++ b/frontend/src/core/threads/composer-draft.ts @@ -1,9 +1,15 @@ +import { + readConversationReferences, + type ConversationReference, +} from "@/core/conversation-references"; + const COMPOSER_DRAFT_VERSION = 1; const COMPOSER_DRAFT_PREFIX = "deerflow:composer-draft:v1"; export type ComposerDraft = { text: string; skillName: string | null; + conversationReferences?: ConversationReference[]; }; export type ComposerDraftStorage = Pick< @@ -56,6 +62,7 @@ export function readComposerDraft( version?: unknown; text?: unknown; skillName?: unknown; + conversationReferences?: unknown; }; if ( parsed.version !== COMPOSER_DRAFT_VERSION || @@ -65,7 +72,23 @@ export function readComposerDraft( return null; } + const references = Array.isArray(parsed.conversationReferences) + ? readConversationReferences({ + conversation_references: parsed.conversationReferences.map( + (item: unknown) => { + if (!item || typeof item !== "object") return null; + const value = item as Record; + return { + thread_id: value.threadId, + title: value.title, + agent_name: value.agentName, + }; + }, + ), + }) + : []; return { + ...(references.length ? { conversationReferences: references } : {}), text: parsed.text, skillName: parsed.skillName, }; @@ -83,7 +106,11 @@ export function writeComposerDraft( if (!storage) { return; } - if (!draft.text && !draft.skillName) { + if ( + !draft.text && + !draft.skillName && + !draft.conversationReferences?.length + ) { storage.removeItem(key); return; } @@ -94,6 +121,9 @@ export function writeComposerDraft( version: COMPOSER_DRAFT_VERSION, text: draft.text, skillName: draft.skillName, + ...(draft.conversationReferences?.length + ? { conversationReferences: draft.conversationReferences } + : {}), }), ); } catch { @@ -124,6 +154,7 @@ export function resolveComposerDraft( } return { + ...draft, text: `/${draft.skillName}${draft.text ? ` ${draft.text}` : ""}`, skillName: null, }; diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 5c583085b3..40cf93679e 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -3438,6 +3438,7 @@ export function useInfiniteThreads( sortOrder: "desc", select: ["thread_id", "updated_at", "values", "metadata"], }, + { enabled = true }: { enabled?: boolean } = {}, ) { const apiClient = getAPIClient(); return useInfiniteQuery< @@ -3459,6 +3460,7 @@ export function useInfiniteThreads( getNextPageParam: (lastPage, allPages) => getInfiniteThreadsNextPageParam(lastPage, allPages), refetchOnWindowFocus: false, + enabled, }); } diff --git a/frontend/tests/e2e/chat.spec.ts b/frontend/tests/e2e/chat.spec.ts index 16cbe552d2..e5f27979b1 100644 --- a/frontend/tests/e2e/chat.spec.ts +++ b/frontend/tests/e2e/chat.spec.ts @@ -321,7 +321,7 @@ test.describe("Chat workspace", () => { await textarea.fill("/dat"); await textarea.press("Enter"); - await expect(page.getByText("/data-analysis")).toBeVisible(); + await expect(page.getByText("@data-analysis")).toBeVisible(); const skillInput = page.getByRole("textbox", { name: /how can i assist you/i, }); @@ -334,7 +334,7 @@ test.describe("Chat workspace", () => { await page.reload(); - await expect(page.getByText("/data-analysis")).toBeVisible(); + await expect(page.getByText("@data-analysis")).toBeVisible(); await expect( page.getByRole("textbox", { name: /how can i assist you/i, @@ -452,6 +452,22 @@ test.describe("Chat workspace", () => { timeout: 10_000, }); + // A new user edit must still save, even when it repeats the accepted text. + await page + .getByPlaceholder(/how can i assist you/i) + .fill("Send this immediately"); + await expect + .poll(() => + page.evaluate(() => Object.values(window.sessionStorage).join("\n")), + ) + .toContain("Send this immediately"); + await page.getByPlaceholder(/how can i assist you/i).fill(""); + await expect + .poll(() => + page.evaluate(() => Object.values(window.sessionStorage).join("\n")), + ) + .not.toContain("Send this immediately"); + await page.reload(); await expect(page.getByPlaceholder(/how can i assist you/i)).toHaveValue( "", @@ -650,7 +666,7 @@ test.describe("Chat workspace", () => { await textarea.press("Enter"); - await expect(page.getByText("/data-analysis")).toBeVisible(); + await expect(page.getByText("@data-analysis")).toBeVisible(); const skillInput = page.getByRole("textbox", { name: /how can i assist you/i, }); @@ -688,7 +704,7 @@ test.describe("Chat workspace", () => { page.getByRole("option", { name: /data-analysis/i }), ).toBeVisible(); await textarea.press("Enter"); - await expect(page.getByText("/data-analysis")).toBeVisible(); + await expect(page.getByText("@data-analysis")).toBeVisible(); const skillInput = page.getByRole("textbox", { name: /how can i assist you/i, @@ -712,8 +728,8 @@ test.describe("Chat workspace", () => { await skillInput.press("Enter"); - await expect(page.getByText("/frontend-design")).toBeVisible(); - await expect(page.getByText("/data-analysis")).toBeHidden(); + await expect(page.getByText("@frontend-design")).toBeVisible(); + await expect(page.getByText("@data-analysis")).toBeHidden(); await skillInput.pressSequentially("polish the composer"); await skillInput.press("Enter"); @@ -778,7 +794,7 @@ test.describe("Chat workspace", () => { page.getByRole("option", { name: /data-analysis/i }), ).toBeVisible(); await textarea.press("Enter"); - await expect(page.getByText("/data-analysis")).toBeVisible(); + await expect(page.getByText("@data-analysis")).toBeVisible(); const skillInput = page.getByRole("textbox", { name: /how can i assist you/i, @@ -934,7 +950,7 @@ test.describe("Chat workspace", () => { await textarea.press("ArrowDown"); await textarea.press("Enter"); - await expect(page.getByText("/frontend-design")).toBeVisible(); + await expect(page.getByText("@frontend-design")).toBeVisible(); await expect( page.getByRole("textbox", { name: /how can i assist you/i }), ).toBeVisible(); diff --git a/frontend/tests/e2e/mentions.spec.ts b/frontend/tests/e2e/mentions.spec.ts new file mode 100644 index 0000000000..7c7043c697 --- /dev/null +++ b/frontend/tests/e2e/mentions.spec.ts @@ -0,0 +1,304 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api"; + +const composer = (page: Page) => + page.getByRole("textbox", { name: /how can i assist you/i }); +const nextRun = (page: Page) => + page.waitForRequest( + (r) => r.method() === "POST" && r.url().includes("/runs/stream"), + ); +test.beforeEach(async ({ page }) => { + mockLangGraphAPI(page, { + threads: [ + { + thread_id: MOCK_THREAD_ID, + title: "Writer brief", + updated_at: "2026-09-01T00:00:00Z", + }, + ], + skills: [ + { name: "research", description: "Research a topic", enabled: true }, + { name: "writing", description: "Write a report", enabled: true }, + ], + projects: [{ id: "proj-1", name: "Research project" }], + projectDocuments: [ + { id: "doc-1", project_id: "proj-1", name: "report.pdf", size_bytes: 10 }, + ], + }); + await page.route("**/api/features", (route) => + route.fulfill({ + json: { + agents_api: { enabled: true }, + browser_control: { enabled: false }, + mcp_tasks: { enabled: false }, + knowledge_base: { scope_selection_enabled: false }, + conversation_references: { enabled: true, max_references: 3 }, + }, + }), + ); +}); +test("mid-draft skill selection preserves both text and caret and submits the activation", async ({ + page, +}) => { + await page.goto("/workspace/chats/new"); + const input = composer(page); + await input.fill("Use carefully"); + await input.press("Home"); + for (let i = 0; i < 4; i++) await input.press("ArrowRight"); + await input.pressSequentially("@res"); + await expect( + page.getByRole("option", { name: "research Research a topic" }), + ).toBeVisible(); + await input.press("Enter"); + await expect(page.getByTestId("inline-skill-reference")).toBeVisible(); + await expect(input).toHaveText("Use ✦research carefully"); + await input.pressSequentially("it"); + await expect(input).toHaveText("Use ✦research it carefully"); + const request = nextRun(page); + await input.press("Enter"); + expect( + JSON.stringify((await request).postDataJSON().input.messages), + ).toContain("Use @research it carefully"); +}); +test("keeps multiple inline skills and restores their positions on reload", async ({ + page, +}) => { + await page.goto("/workspace/chats/new"); + const input = composer(page); + await input.fill("@res"); + await page.getByRole("option", { name: "research Research a topic" }).click(); + await input.pressSequentially("Keep this @wri"); + await page.getByRole("option", { name: "writing Write a report" }).click(); + await expect(input).toHaveText("✦research Keep this ✦writing "); + await page.reload(); + await expect(input).toHaveText("✦research Keep this ✦writing "); + const request = nextRun(page); + await input.press("Enter"); + const message = (await request).postDataJSON().input.messages.at(-1); + expect(message.additional_kwargs.skill_references).toEqual([ + "research", + "writing", + ]); +}); +test("emails and dismissed queries stay literal", async ({ page }) => { + await page.goto("/workspace/chats/new"); + const input = composer(page); + await input.fill("a@example.com"); + await expect(page.getByTestId("mention-picker")).toBeHidden(); + await input.fill("@res"); + await expect(page.getByTestId("mention-picker")).toBeVisible(); + await input.press("Escape"); + await expect(page.getByTestId("mention-picker")).toBeHidden(); + await expect(input).toHaveValue("@res"); +}); +test("attaches a document to a new project thread and submits its confirmed file", async ({ + page, +}) => { + await page.goto("/workspace/chats/new?project=proj-1"); + const input = composer(page); + await input.fill("Read @report"); + await page.getByRole("option", { name: "report.pdf" }).click(); + await expect(page.getByTestId("project-attachment-chip")).toContainText( + "report.pdf", + ); + await expect(input).toHaveText("Read ▤report.pdf "); + await expect(page).not.toHaveURL(/\/new/); + await page.reload(); + await expect(page.getByTestId("project-attachment-chip")).toContainText( + "report.pdf", + ); + await expect(input).toHaveText("Read ▤report.pdf "); + const request = nextRun(page); + await input.press("Enter"); + expect( + JSON.stringify((await request).postDataJSON().input.messages), + ).toContain("/mnt/user-data/uploads/report.pdf"); +}); +test("restores a conversation reference and sends its run-scoped ID", async ({ + page, +}) => { + await page.goto("/workspace/chats/new"); + const input = composer(page); + await input.fill("Summarize @Writer"); + await page.getByRole("option", { name: "Writer brief" }).click(); + await expect(page.getByTestId("conversation-reference-chip")).toContainText( + "Writer brief", + ); + await page.reload(); + await expect(page.getByTestId("conversation-reference-chip")).toContainText( + "Writer brief", + ); + const request = nextRun(page); + await input.press("Enter"); + expect( + (await request).postDataJSON().context.conversation_references, + ).toEqual([MOCK_THREAD_ID]); +}); +test("mouse upload entry and mobile picker stay usable", async ({ + page, +}, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/workspace/chats/new"); + await page.getByTestId("mention-button").click(); + const picker = page.getByTestId("mention-picker"); + await expect(picker).toBeVisible(); + const box = await picker.boundingBox(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(390); + await page.screenshot({ path: testInfo.outputPath("mentions-mobile.png") }); + const chooser = page.waitForEvent("filechooser"); + await page.getByRole("option", { name: "Upload a file" }).click(); + await ( + await chooser + ).setFiles({ + name: "note.txt", + mimeType: "text/plain", + buffer: Buffer.from("hello"), + }); + await expect(page.getByText("note.txt", { exact: true })).toBeVisible(); +}); + +for (const metadataState of ["pending", "failed"] as const) { + test(`keeps confirmed project files after materialization when metadata is ${metadataState}`, async ({ + page, + }) => { + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + await page.route( + /\/api\/(?:langgraph\/)?threads\/[^/]+$/, + async (route) => { + if (route.request().method() !== "GET") return route.fallback(); + if (metadataState === "pending") await pending; + return route.fulfill({ + status: 503, + json: { detail: "metadata unavailable" }, + }); + }, + ); + try { + await page.goto("/workspace/chats/new?project=proj-1"); + await composer(page).fill("Read @report"); + await page.getByRole("option", { name: "report.pdf" }).click(); + await expect(page).not.toHaveURL(/\/new/); + await expect(page.getByTestId("project-attachment-chip")).toBeVisible(); + await page.getByTestId("mention-button").click(); + await expect( + page.getByRole("option", { name: "report.pdf" }), + ).toBeVisible(); + await expect( + page.getByText("Open a project chat to reference project files."), + ).toBeHidden(); + } finally { + release(); + } + }); +} + +for (const enabledAfterRetry of [true, false]) { + test(`preserves references across a failed discovery and retries to enabled=${enabledAfterRetry}`, async ({ + page, + }) => { + await page.goto("/workspace/chats/new"); + const input = composer(page); + await input.fill("Summarize @Writer"); + await page.getByRole("option", { name: "Writer brief" }).click(); + await expect(page.getByTestId("conversation-reference-chip")).toBeVisible(); + let failed = true; + await page.route("**/api/features", (route) => + failed + ? route.fulfill({ status: 503, body: "Temporarily unavailable" }) + : enabledAfterRetry + ? route.fallback() + : route.fulfill({ + json: { + agents_api: { enabled: true }, + conversation_references: { enabled: false, max_references: 0 }, + }, + }), + ); + await page.reload(); + const retry = page.getByTestId("retry-conversation-capability"); + await expect(retry).toBeVisible(); + await expect(page.getByTestId("conversation-reference-chip")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Submit", exact: true }), + ).toBeDisabled(); + await input.press("End"); + await input.pressSequentially("after retry"); + await expect + .poll(() => + page.evaluate(() => + Object.values(sessionStorage).some( + (value) => + value.includes("after retry") && + value.includes("ref:conversation:"), + ), + ), + ) + .toBe(true); + await page.reload(); + await expect(retry).toBeVisible(); + await expect(page.getByTestId("conversation-reference-chip")).toBeVisible(); + failed = false; + await retry.click(); + await expect(retry).toBeHidden(); + await expect( + page.getByRole("button", { name: "Submit", exact: true }), + ).toBeEnabled(); + if (enabledAfterRetry) + await expect( + page.getByTestId("conversation-reference-chip"), + ).toBeVisible(); + else + await expect( + page.getByTestId("conversation-reference-chip"), + ).toBeHidden(); + const request = nextRun(page); + await input.press("Enter"); + const body = (await request).postDataJSON(); + expect(body.context.conversation_references ?? []).toEqual( + enabledAfterRetry ? [MOCK_THREAD_ID] : [], + ); + expect(JSON.stringify(body.input.messages)).toContain("after retry"); + }); +} + +test("polish preserves reordered overlapping reference labels and their IDs", async ({ + page, +}) => { + await page.route("**/api/input-polish", (route) => + route.fulfill({ + json: { + rewritten_text: "Use @research-tools first, then @research.", + changed: true, + }, + }), + ); + await page.goto("/workspace/chats/new"); + const input = composer(page); + await input.fill( + "Use @[research](ref:skill:research) after @[research-tools](ref:skill:research-tools)", + ); + await expect(page.getByTestId("inline-skill-reference")).toHaveCount(2); + await page.getByTestId("polish-input-button").click(); + await expect(input).toHaveText("Use ✦research-tools first, then ✦research."); + await expect( + page.getByTestId("inline-skill-reference").first(), + ).toHaveAttribute( + "data-reference", + "@[research-tools](ref:skill:research-tools)", + ); + const request = nextRun(page); + await input.press("Enter"); + const message = (await request).postDataJSON().input.messages.at(-1); + expect(message.additional_kwargs.skill_references).toEqual([ + "research-tools", + "research", + ]); + expect(JSON.stringify(message)).toContain( + "Use @research-tools first, then @research.", + ); +}); diff --git a/frontend/tests/unit/components/workspace/input-box-agent-skill-hydration.dom.test.tsx b/frontend/tests/unit/components/workspace/input-box-agent-skill-hydration.dom.test.tsx index b8f822ed4e..b3a3df25bb 100644 --- a/frontend/tests/unit/components/workspace/input-box-agent-skill-hydration.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/input-box-agent-skill-hydration.dom.test.tsx @@ -113,9 +113,7 @@ describe("InputBox agent skill draft hydration", () => { agentSkillNames: undefined, }); - expect( - screen.queryByRole("button", { name: "Remove /research" }), - ).toBeNull(); + expect(screen.queryByRole("button", { name: "Remove skill" })).toBeNull(); expect(window.sessionStorage.getItem(draftKey)).toContain( '"skillName":"research"', ); @@ -160,9 +158,7 @@ describe("InputBox agent skill draft hydration", () => { ); await waitFor(() => { - expect( - screen.getByRole("button", { name: "Remove /research" }), - ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Remove skill" })).toBeTruthy(); }); expect(screen.getByRole("textbox").textContent).toBe("topic"); }); @@ -179,9 +175,7 @@ describe("InputBox agent skill draft hydration", () => { }); await waitFor(() => { - expect( - screen.getByRole("button", { name: "Remove /research" }), - ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Remove skill" })).toBeTruthy(); }); }); }); diff --git a/frontend/tests/unit/components/workspace/input-box-helpers.test.ts b/frontend/tests/unit/components/workspace/input-box-helpers.test.ts index 11caddaa42..44b749b92e 100644 --- a/frontend/tests/unit/components/workspace/input-box-helpers.test.ts +++ b/frontend/tests/unit/components/workspace/input-box-helpers.test.ts @@ -518,3 +518,14 @@ describe("findSuggestionTemplatePlaceholder", () => { expect(findSuggestionTemplatePlaceholder("no placeholder here")).toBeNull(); }); }); + +it("never offers filesystem-discovered names outside the activation grammar", () => { + const names = ["research", "a--b", "a_b", "Research", "a-", "context"]; + expect( + getMatchingSkillSuggestions( + names.map((name) => makeSkill(name)), + "", + [], + ).map((item) => item.name), + ).toEqual(["research", "context"]); +}); diff --git a/frontend/tests/unit/components/workspace/input-box-mentions.dom.test.tsx b/frontend/tests/unit/components/workspace/input-box-mentions.dom.test.tsx new file mode 100644 index 0000000000..74a617f5b9 --- /dev/null +++ b/frontend/tests/unit/components/workspace/input-box-mentions.dom.test.tsx @@ -0,0 +1,742 @@ +import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import type { ComponentProps, ReactNode } from "react"; + +import { PromptInputProvider } from "@/components/ai-elements/prompt-input"; +import { InputBox } from "@/components/workspace/input-box"; +import { referenceToken } from "@/components/workspace/mentions/inline-references"; +import { ThreadContext } from "@/components/workspace/messages/context"; +import { AuthProvider } from "@/core/auth/AuthProvider"; +import { DEFAULT_LOCALE } from "@/core/i18n"; +import { I18nProvider } from "@/core/i18n/context"; +import { + buildComposerDraftKey, + writeComposerDraft, +} from "@/core/threads/composer-draft"; + +rs.mock("next/navigation", () => ({ + useRouter: () => ({ push: rs.fn(), replace: rs.fn(), refresh: rs.fn() }), + usePathname: () => "/workspace", + useSearchParams: () => new URLSearchParams(), +})); + +rs.mock("@/core/models/hooks", () => ({ + useModels: () => ({ + models: [], + tokenUsageEnabled: false, + isLoading: false, + isFetching: false, + error: null, + refetch: rs.fn(), + }), +})); + +const skillsQuery = { + isLoading: false, + error: null as Error | null, + refetch: rs.fn(), +}; +rs.mock("@/core/skills/hooks", () => ({ + useSkills: () => ({ + skills: [ + { + name: "research", + description: "Research a topic", + category: "general", + license: "MIT", + enabled: true, + editable: false, + }, + ], + ...skillsQuery, + }), +})); + +// Each test gets its own thread id: the composer persists the selected skill in +// a debounced, thread-scoped draft, and a shared id would let the first test's +// selection land in the second test's storage key. +function renderComposer( + threadId = "mentions-thread", + onSubmit = rs.fn(), + onPrepareThread = rs.fn(), + props: Partial> = {}, + isMock = true, +) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const tree: ReactNode = ( + + + + + + + + + + + + ); + return render(tree); +} + +const attach = rs.fn(); +const capability = { + enabled: true, + maxReferences: 3, + isLoading: false, + isSuccess: true, + error: null as Error | null, + refetch: rs.fn(), +}; +const polish = rs.fn(); +rs.mock("@/core/input-polish/api", () => ({ + polishInputDraft: (...args: unknown[]) => polish(...args), +})); +rs.mock("@/core/features/hooks", () => ({ + useConversationReferencesCapability: () => capability, +})); +rs.mock("@/core/projects/api", () => ({ + attachProjectDocument: (...args: unknown[]) => attach(...args), +})); +rs.mock("@/core/projects/hooks", () => ({ + useInfiniteProjectDocuments: () => ({ + data: { + pages: [ + { + documents: [ + { + id: "doc-1", + name: "report.pdf", + size_bytes: 10, + content_missing: false, + }, + ], + }, + ], + }, + isPending: false, + }), +})); +rs.mock("@/core/threads/hooks", () => ({ + useInfiniteThreads: () => ({ + data: { + pages: [ + [ + { + thread_id: "source-1", + values: { title: "Writer brief" }, + metadata: { agent_name: "writer" }, + }, + ...[2, 3, 4].map((id) => ({ + thread_id: `source-${id}`, + values: { title: `Brief ${id}` }, + metadata: {}, + })), + ], + ], + }, + isPending: false, + }), +})); + +beforeEach(() => { + capability.enabled = true; + capability.maxReferences = 3; + capability.isLoading = false; + capability.isSuccess = true; + capability.error = null; + capability.refetch.mockReset(); + polish.mockReset(); + skillsQuery.isLoading = false; + skillsQuery.error = null; + skillsQuery.refetch.mockReset(); + attach.mockReset(); + attach.mockResolvedValue({ + filename: "report.pdf", + size_bytes: 10, + virtual_path: "/mnt/user-data/uploads/report.pdf", + artifact_url: "/artifact", + }); +}); +afterEach(() => { + cleanup(); + window.sessionStorage.clear(); + rs.restoreAllMocks(); +}); +function enterMention( + container: HTMLElement, + text: string, + caret = text.length, +) { + const input = container.querySelector("textarea"); + if (!input) { + const editor = container.querySelector( + '[contenteditable="true"]', + )!; + const node = document.createTextNode(" " + text); + editor.append(node); + const range = document.createRange(); + range.setStart(node, 1 + caret); + range.collapse(true); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + fireEvent.input(editor); + return editor as HTMLTextAreaElement; + } + fireEvent.focus(input); + fireEvent.change(input, { + target: { value: text, selectionStart: caret, selectionEnd: caret }, + }); + return input; +} + +describe("unified composer mentions", () => { + it("keeps a skill inline in the middle and sends its explicit activation metadata", async () => { + const submit = rs.fn(); + const { container } = renderComposer("skill-mention", submit); + const input = enterMention(container, "Use @res carefully", 8); + fireEvent.keyDown(input, { key: "Enter" }); + await waitFor(() => + expect(screen.getByTestId("inline-skill-reference")).toBeTruthy(), + ); + const editor = container.querySelector('[contenteditable="true"]')!; + await waitFor(() => + expect(editor.textContent).toBe("Use ✦research carefully"), + ); + fireEvent.keyDown(editor, { key: "Enter" }); + await waitFor(() => expect(submit).toHaveBeenCalled()); + expect(submit.mock.calls[0]![0].text).toBe("Use @research carefully"); + expect(submit.mock.calls[0]![1].additionalKwargs.skill_references).toEqual([ + "research", + ]); + }); + it("leaves emails and cancelled or composing queries as text", () => { + const { container } = renderComposer(); + enterMention(container, "a@example.com"); + expect(screen.queryByTestId("mention-picker")).toBeNull(); + const input = enterMention(container, "@res"); + fireEvent.keyDown(input, { key: "Enter", keyCode: 229 }); + expect(input.value).toBe("@res"); + expect(screen.queryByRole("button", { name: "Remove skill" })).toBeNull(); + fireEvent.keyDown(input, { key: "Escape" }); + expect(screen.queryByTestId("mention-picker")).toBeNull(); + expect(input.value).toBe("@res"); + }); + it("prepares the project thread, attaches once, and sends the confirmed file", async () => { + const submit = rs.fn(); + const prepare = rs.fn(); + const { container } = renderComposer("file-mention", submit, prepare); + enterMention(container, "Read @report"); + fireEvent.click(screen.getByRole("option", { name: "report.pdf" })); + await waitFor(() => + expect(screen.getByTestId("project-attachment-chip")).toBeTruthy(), + ); + expect(prepare).toHaveBeenCalledTimes(1); + expect(attach).toHaveBeenCalledWith("project-1", "doc-1", "file-mention"); + enterMention(container, "Read @report"); + fireEvent.click(screen.getByRole("option", { name: "report.pdf" })); + expect(attach).toHaveBeenCalledTimes(1); + fireEvent.submit(container.querySelector("form")!); + await waitFor(() => expect(submit).toHaveBeenCalled()); + expect(submit.mock.calls[0]![1].additionalKwargs.files).toEqual([ + { + filename: "report.pdf", + size: 10, + path: "/mnt/user-data/uploads/report.pdf", + status: "uploaded", + }, + ]); + }); + it("keeps the query after an attachment error and permits retry", async () => { + attach.mockRejectedValueOnce(new Error("offline")); + const { container } = renderComposer(); + const input = enterMention(container, "Read @report"); + fireEvent.click(screen.getByRole("option", { name: "report.pdf" })); + await waitFor(() => + expect(screen.getByRole("alert").textContent).toContain("Could not add"), + ); + expect(input.value).toBe("Read @report"); + expect(screen.queryByTestId("project-attachment-chip")).toBeNull(); + fireEvent.click(screen.getByRole("option", { name: "report.pdf" })); + await waitFor(() => + expect(screen.getByTestId("project-attachment-chip")).toBeTruthy(), + ); + }); + it("persists a conversation reference with its draft and sends its ID and display metadata", async () => { + const submit = rs.fn(); + const rendered = renderComposer("reference-draft", submit); + enterMention(rendered.container, "Review @Writer"); + fireEvent.click(screen.getByRole("option", { name: "Writer brief" })); + await waitFor(() => + expect(screen.getByTestId("conversation-reference-chip")).toBeTruthy(), + ); + fireEvent(window, new Event("pagehide")); + rendered.unmount(); + const { container } = renderComposer("reference-draft", submit); + await waitFor(() => + expect(screen.getByTestId("conversation-reference-chip")).toBeTruthy(), + ); + fireEvent.submit(container.querySelector("form")!); + await waitFor(() => expect(submit).toHaveBeenCalled()); + expect(submit.mock.calls[0]![1].conversationReferences).toEqual([ + "source-1", + ]); + expect( + submit.mock.calls[0]![1].additionalKwargs.conversation_references, + ).toEqual([ + { thread_id: "source-1", title: "Writer brief", agent_name: "writer" }, + ]); + }); + it("hides conversations when the deployment disables references", () => { + capability.enabled = false; + const { container } = renderComposer(); + enterMention(container, "@"); + expect(screen.queryByRole("option", { name: "Writer brief" })).toBeNull(); + }); + it("the attachment button opens the same picker", () => { + renderComposer(); + fireEvent.click(screen.getByTestId("add-attachments-button")); + expect(screen.getByTestId("mention-picker")).toBeTruthy(); + expect(screen.getByRole("option", { name: "Upload a file" })).toBeTruthy(); + }); + it("does not select upload automatically for an unknown query", () => { + const { container } = renderComposer(); + enterMention(container, "@missing"); + const upload = screen.getByRole("option", { name: "Upload a file" }); + expect(upload.getAttribute("aria-selected")).toBe("false"); + }); + it("ignores late attachment completion after the composer is replaced", async () => { + let finish!: (value: unknown) => void; + attach.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + const first = renderComposer("old-thread"); + enterMention(first.container, "Read @report"); + fireEvent.click(screen.getByRole("option", { name: "report.pdf" })); + await waitFor(() => expect(attach).toHaveBeenCalled()); + first.unmount(); + const second = renderComposer("new-thread"); + enterMention(second.container, "Keep this draft"); + finish({ + filename: "report.pdf", + size_bytes: 10, + virtual_path: "/mnt/user-data/uploads/report.pdf", + artifact_url: "/artifact", + }); + await waitFor(() => + expect(second.container.querySelector("textarea")!.value).toBe( + "Keep this draft", + ), + ); + expect(screen.queryByTestId("project-attachment-chip")).toBeNull(); + }); + + it("enforces the conversation cap while allowing removal and reselection", () => { + const { container } = renderComposer(); + for (const name of ["Writer brief", "Brief 2", "Brief 3"]) { + enterMention(container, `Review @${name.split(" ")[0]}`); + fireEvent.click(screen.getByRole("option", { name })); + } + enterMention(container, "Review @Brief"); + const fourth = screen.getByRole("option", { name: "Brief 4" }); + expect(fourth.hasAttribute("disabled")).toBe(true); + screen.getAllByTestId("conversation-reference-chip")[0]!.remove(); + fireEvent.input(container.querySelector('[contenteditable="true"]')!); + expect( + screen.getByRole("option", { name: "Brief 4" }).hasAttribute("disabled"), + ).toBe(false); + }); +}); + +function saveDraft(threadId: string, text: string, extra = {}) { + writeComposerDraft( + window.sessionStorage, + buildComposerDraftKey({ + userId: "user-1", + agentName: null, + threadId, + }), + { text, skillName: null, ...extra }, + ); +} + +describe("reference review regressions", () => { + for (const entry of ["paste", "draft"] as const) { + it(`resolves conversation tokens from ${entry} without another editor input`, async () => { + const text = + referenceToken("conversation", "source-1", "Writer brief") + + " summarize"; + if (entry === "draft") saveDraft("raw-token", text); + const submit = rs.fn(); + const { container } = renderComposer("raw-token", submit); + if (entry === "paste") enterMention(container, text); + await waitFor(() => + expect(screen.getByTestId("conversation-reference-chip")).toBeTruthy(), + ); + fireEvent.submit(container.querySelector("form")!); + await waitFor(() => expect(submit).toHaveBeenCalledTimes(1)); + expect(submit.mock.calls[0]![1].conversationReferences).toEqual([ + "source-1", + ]); + expect( + submit.mock.calls[0]![1].additionalKwargs.conversation_references, + ).toEqual([{ thread_id: "source-1", title: "Writer brief" }]); + }); + } + it("drops disabled restored context and renders its label as ordinary text", async () => { + capability.enabled = false; + saveDraft( + "disabled-restore", + referenceToken("conversation", "source-1", "Writer brief") + " summarize", + { + conversationReferences: [ + { threadId: "source-1", title: "Writer brief" }, + ], + }, + ); + const submit = rs.fn(); + const { container } = renderComposer("disabled-restore", submit); + await waitFor(() => + expect(screen.queryByTestId("conversation-reference-chip")).toBeNull(), + ); + fireEvent.submit(container.querySelector("form")!); + await waitFor(() => expect(submit).toHaveBeenCalled()); + expect(submit.mock.calls[0]![1].conversationReferences).toBeUndefined(); + expect(submit.mock.calls[0]![0].text).toContain("@Writer brief"); + }); + it("uses the current capability limit on paste, edits, and picker counts", async () => { + capability.maxReferences = 4; + const submit = rs.fn(); + const { container } = renderComposer("cap-four", submit); + enterMention( + container, + [1, 2, 3, 4, 5] + .map((id) => + referenceToken("conversation", `source-${id}`, `Brief ${id}`), + ) + .join(" "), + ); + await waitFor(() => + expect(screen.getAllByTestId("conversation-reference-chip")).toHaveLength( + 4, + ), + ); + const editor = container.querySelector('[contenteditable="true"]')!; + fireEvent.input(editor); + fireEvent.submit(container.querySelector("form")!); + await waitFor(() => expect(submit).toHaveBeenCalled()); + expect(submit.mock.calls[0]![1].conversationReferences).toEqual([ + "source-1", + "source-2", + "source-3", + "source-4", + ]); + }); + it("does not send while conversation capability is loading", async () => { + capability.isLoading = true; + capability.isSuccess = false; + const submit = rs.fn(); + const { container } = renderComposer("cap-loading", submit); + enterMention( + container, + referenceToken("conversation", "source-1", "Brief"), + ); + fireEvent.submit(container.querySelector("form")!); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(submit).not.toHaveBeenCalled(); + }); + it("rejects pasted skill batches over the cap without losing the draft", async () => { + const submit = rs.fn(); + const { container } = renderComposer("skill-cap", submit); + enterMention( + container, + Array.from({ length: 17 }, (_, i) => + referenceToken("skill", `skill-${i}`, `skill-${i}`), + ).join(" "), + ); + fireEvent.submit(container.querySelector("form")!); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(submit).not.toHaveBeenCalled(); + expect(screen.getAllByTestId("inline-skill-reference")).toHaveLength(17); + }); + it("toggles an already selected skill instead of inserting duplicate tokens", async () => { + const { container } = renderComposer("toggle-skill"); + enterMention(container, "@res"); + fireEvent.click( + screen.getByRole("option", { name: "research Research a topic" }), + ); + enterMention(container, "@res"); + const option = screen.getByRole("option", { + name: "research Research a topic", + }); + expect(option.getAttribute("aria-selected")).toBe("true"); + fireEvent.click(option); + await waitFor(() => + expect(screen.queryByTestId("inline-skill-reference")).toBeNull(), + ); + }); + it("reports skills loading and retries a failed skills query", () => { + skillsQuery.error = new Error("offline"); + renderComposer("skill-error"); + fireEvent.click(screen.getByTestId("mention-button")); + expect(screen.getByRole("alert").textContent).toContain("Could not load"); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(skillsQuery.refetch).toHaveBeenCalledTimes(1); + }); + it("exposes the keyboard highlight separately from actual selections", () => { + const { container } = renderComposer("aria"); + const input = enterMention(container, "@res"); + const option = screen.getByRole("option", { + name: "research Research a topic", + }); + expect(option.getAttribute("aria-selected")).toBe("false"); + expect(option.id).not.toBe(""); + expect(input.getAttribute("aria-activedescendant")).toBe(option.id); + }); + it("locks legacy skill removal until attachment migration settles", async () => { + let finish!: (value: unknown) => void; + attach.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + saveDraft("new", "Read @report", { skillName: "research" }); + const materialized = rs.fn(); + const { container } = renderComposer("materialized", rs.fn(), rs.fn(), { + draftThreadId: "new", + onReferenceFileAttached: materialized, + }); + await waitFor(() => + expect(screen.getByRole("button", { name: "Remove skill" })).toBeTruthy(), + ); + fireEvent.click(screen.getByTestId("mention-button")); + fireEvent.click(screen.getByRole("option", { name: "report.pdf" })); + await waitFor(() => expect(attach).toHaveBeenCalled()); + const remove = screen.getByRole("button", { name: "Remove skill" }); + expect(remove.hasAttribute("disabled")).toBe(true); + fireEvent.click(remove); + finish({ + filename: "report.pdf", + size_bytes: 10, + virtual_path: "/mnt/user-data/uploads/report.pdf", + artifact_url: "/artifact", + }); + await waitFor(() => expect(materialized).toHaveBeenCalledTimes(1)); + expect(container.textContent).toContain("@research"); + }); +}); + +it("announces a pending skills request in the picker", () => { + skillsQuery.isLoading = true; + renderComposer("skills-pending"); + fireEvent.click(screen.getByTestId("mention-button")); + expect(screen.getByRole("listbox").getAttribute("aria-busy")).toBe("true"); + expect(screen.getByRole("status").textContent).toContain("Loading"); +}); + +it("the picker search exposes its active option while selected state remains independent", () => { + renderComposer("search-aria"); + fireEvent.click(screen.getByTestId("mention-button")); + const search = screen.getByRole("combobox"); + const research = screen.getByRole("option", { + name: "research Research a topic", + }); + expect(search.getAttribute("aria-activedescendant")).toBe(research.id); + expect(research.getAttribute("aria-selected")).toBe("false"); + fireEvent.keyDown(search, { key: "ArrowDown" }); + expect(search.getAttribute("aria-activedescendant")).toBe( + screen.getByRole("option", { name: "report.pdf" }).id, + ); + expect(research.getAttribute("aria-selected")).toBe("false"); +}); + +it("pasted file labels never manufacture confirmed attachment context", async () => { + const submit = rs.fn(); + const { container } = renderComposer("file-paste", submit); + enterMention( + container, + referenceToken("file", "doc-1", "report.pdf") + " read", + ); + fireEvent.submit(container.querySelector("form")!); + await waitFor(() => expect(submit).toHaveBeenCalled()); + expect(submit.mock.calls[0]![1].additionalKwargs?.files).toBeUndefined(); + expect(attach).not.toHaveBeenCalled(); +}); + +it("a capability rerender before send acceptance cannot revive the accepted draft", async () => { + let accepted!: () => void; + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const submit = rs.fn((_message, options) => { + accepted = options.onSent; + return pending; + }); + const { container } = renderComposer("accepted-rerender", submit); + enterMention(container, "Accepted task"); + fireEvent.submit(container.querySelector("form")!); + await waitFor(() => expect(submit).toHaveBeenCalledTimes(1)); + // Opening the picker changes state without editing the submitted snapshot. + fireEvent.click(screen.getByTestId("mention-button")); + await waitFor(() => + expect(screen.getByTestId("mention-picker")).toBeTruthy(), + ); + accepted(); + fireEvent(window, new Event("pagehide")); + expect(Object.values(window.sessionStorage).join("\n")).not.toContain( + "Accepted task", + ); + release(); +}); + +describe("reference discovery failures and polish round trips", () => { + it("preserves restored references and blocks sending until capability retry succeeds", async () => { + capability.enabled = false; + capability.maxReferences = 0; + capability.isSuccess = false; + capability.error = new Error("503 temporary"); + const token = referenceToken("conversation", "source-1", "Writer brief"); + const text = token + " summarize"; + saveDraft("cap-error", text, { + conversationReferences: [ + { threadId: "source-1", title: "Writer brief", agentName: "writer" }, + ], + }); + const submit = rs.fn(); + const { container } = renderComposer("cap-error", submit); + await waitFor(() => + expect(screen.getByTestId("conversation-reference-chip")).toBeTruthy(), + ); + fireEvent.submit(container.querySelector("form")!); + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(submit).not.toHaveBeenCalled(); + const key = buildComposerDraftKey({ + userId: "user-1", + agentName: null, + threadId: "cap-error", + }); + expect(JSON.parse(window.sessionStorage.getItem(key)!).text).toBe(text); + expect( + JSON.parse(window.sessionStorage.getItem(key)!).conversationReferences, + ).toEqual([ + { threadId: "source-1", title: "Writer brief", agentName: "writer" }, + ]); + capability.refetch.mockImplementation(() => { + capability.isSuccess = true; + capability.enabled = true; + capability.maxReferences = 3; + capability.error = null; + }); + fireEvent.click(screen.getByTestId("retry-conversation-capability")); + expect(capability.refetch).toHaveBeenCalledTimes(1); + // An ordinary editor update observes the recovered mock capability. + enterMention(container, " now"); + fireEvent.submit(container.querySelector("form")!); + await waitFor(() => expect(submit).toHaveBeenCalledTimes(1)); + expect(submit.mock.calls[0]![1].conversationReferences).toEqual([ + "source-1", + ]); + expect( + submit.mock.calls[0]![1].additionalKwargs.conversation_references[0] + .agent_name, + ).toBe("writer"); + }); + + it("offers capability retry from the picker without a selected conversation", async () => { + capability.enabled = false; + capability.isSuccess = false; + capability.error = new Error("503 temporary"); + renderComposer("picker-cap-error"); + fireEvent.click(screen.getByTestId("mention-button")); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(capability.refetch).toHaveBeenCalledTimes(1); + }); + + it("restores reordered overlapping skill labels in the intended positions and supports undo", async () => { + const short = referenceToken("skill", "research", "research"); + const long = referenceToken("skill", "research-tools", "research-tools"); + const original = `Use ${short} after ${long}`; + saveDraft("polish-prefix", original); + polish.mockResolvedValue({ + rewritten_text: "Use @research-tools first, then @research.", + changed: true, + }); + const submit = rs.fn(); + const { container } = renderComposer( + "polish-prefix", + submit, + rs.fn(), + {}, + false, + ); + await waitFor(() => + expect(screen.getAllByTestId("inline-skill-reference")).toHaveLength(2), + ); + fireEvent.click(screen.getByTestId("polish-input-button")); + await waitFor(() => + expect( + container.querySelector('[contenteditable="true"]')?.textContent, + ).toBe("Use ✦research-tools first, then ✦research."), + ); + expect( + screen + .getAllByTestId("inline-skill-reference") + .map((el) => el.dataset.reference), + ).toEqual([long, short]); + fireEvent.click(screen.getByTestId("polish-input-button")); + await waitFor(() => + expect( + screen + .getAllByTestId("inline-skill-reference") + .map((el) => el.dataset.reference), + ).toEqual([short, long]), + ); + fireEvent.click(screen.getByTestId("polish-input-button")); + await waitFor(() => + expect( + screen + .getAllByTestId("inline-skill-reference") + .map((el) => el.dataset.reference), + ).toEqual([long, short]), + ); + fireEvent.submit(container.querySelector("form")!); + await waitFor(() => expect(submit).toHaveBeenCalledTimes(1)); + expect(submit.mock.calls[0]![0].text).toBe( + "Use @research-tools first, then @research.", + ); + expect(submit.mock.calls[0]![1].additionalKwargs.skill_references).toEqual([ + "research-tools", + "research", + ]); + }); +}); diff --git a/frontend/tests/unit/components/workspace/input-box-skill-suggestion-ime.dom.test.tsx b/frontend/tests/unit/components/workspace/input-box-skill-suggestion-ime.dom.test.tsx index d83f07cef5..980eed4b16 100644 --- a/frontend/tests/unit/components/workspace/input-box-skill-suggestion-ime.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/input-box-skill-suggestion-ime.dom.test.tsx @@ -109,9 +109,7 @@ describe("InputBox skill suggestion IME handling", () => { // IME composition; the catalog must not claim it. fireEvent.keyDown(textarea, { key: "Enter", keyCode: 229 }); - expect( - screen.queryByRole("button", { name: "Remove /research" }), - ).toBeNull(); + expect(screen.queryByRole("button", { name: "Remove skill" })).toBeNull(); expect(textarea.value).toBe("/res"); }); @@ -124,9 +122,7 @@ describe("InputBox skill suggestion IME handling", () => { fireEvent.compositionEnd(textarea); fireEvent.keyDown(textarea, { key: "Enter", keyCode: 13 }); - expect( - screen.queryByRole("button", { name: "Remove /research" }), - ).toBeNull(); + expect(screen.queryByRole("button", { name: "Remove skill" })).toBeNull(); expect(textarea.value).toBe("/res"); }); @@ -141,9 +137,7 @@ describe("InputBox skill suggestion IME handling", () => { fireEvent.keyDown(textarea, { key: "Enter", keyCode: 13 }); - expect( - screen.getByRole("button", { name: "Remove /research" }), - ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Remove skill" })).toBeTruthy(); }); it("still selects the highlighted skill on a plain Enter", () => { @@ -152,8 +146,6 @@ describe("InputBox skill suggestion IME handling", () => { fireEvent.keyDown(textarea, { key: "Enter" }); - expect( - screen.getByRole("button", { name: "Remove /research" }), - ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Remove skill" })).toBeTruthy(); }); }); diff --git a/frontend/tests/unit/components/workspace/mentions/inline-references.test.ts b/frontend/tests/unit/components/workspace/mentions/inline-references.test.ts new file mode 100644 index 0000000000..65a917eeb7 --- /dev/null +++ b/frontend/tests/unit/components/workspace/mentions/inline-references.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "@rstest/core"; + +import { + inlineReferences, + reconcileConversationReferences, + referenceToken, + restoreReferenceLabels, +} from "@/components/workspace/mentions/inline-references"; + +const text = ["one", "two", "one", "self", "three"] + .map((id) => referenceToken("conversation", id, `Title ${id}`)) + .join(" "); +describe("conversation reference reconciliation", () => { + it("uses token order, deduplicates, excludes self, and preserves known display metadata", () => { + const result = reconcileConversationReferences( + text, + [{ threadId: "one", title: "Known", agentName: "writer" }], + { enabled: true, maxReferences: 2, isLoading: false, isSuccess: true }, + "self", + ); + expect(result.references).toEqual([ + { threadId: "one", title: "Known", agentName: "writer" }, + { threadId: "two", title: "Title two" }, + ]); + expect(inlineReferences(result.text).map((ref) => ref.id)).toEqual([ + "one", + "two", + "one", + ]); + expect(result.text).toContain("@Title self @Title three"); + }); + it("retains pending text, then flattens disabled references without losing user words", () => { + expect( + reconcileConversationReferences( + text, + [], + { enabled: false, maxReferences: 0, isLoading: true, isSuccess: false }, + "self", + ), + ).toEqual({ text, references: [] }); + const result = reconcileConversationReferences( + text, + [], + { enabled: false, maxReferences: 3, isLoading: false, isSuccess: true }, + "self", + ); + expect(result.references).toEqual([]); + expect(result.text).toBe( + "@Title one @Title two @Title one @Title self @Title three", + ); + }); +}); + +describe("polished reference labels", () => { + for (const [kind, shortLabel, longLabel] of [ + ["skill", "research", "research-tools"], + ["file", "report", "report.pdf"], + ["conversation", "调研", "调研计划(Q4)"], + ["file", "notes[1]", "notes[1].txt"], + ] as const) { + it(`restores reordered overlapping ${kind} labels: ${longLabel}`, () => { + const short = referenceToken(kind, "short-id", shortLabel); + const long = referenceToken(kind, "long-id", longLabel); + expect( + restoreReferenceLabels( + `Use ${short} after ${long}`, + `Use @${longLabel} first, then @${shortLabel}.`, + ), + ).toBe(`Use ${long} first, then ${short}.`); + }); + } + it("does not consume a longer unselected word", () => { + const token = referenceToken("skill", "research", "research"); + expect( + restoreReferenceLabels( + token, + "Keep @research-tools unchanged; use @research.", + ), + ).toBe(`Keep @research-tools unchanged; use ${token}.`); + }); + it("replaces each occurrence once without scanning inserted tokens", () => { + const token = referenceToken("skill", "id", "research"); + expect( + restoreReferenceLabels( + `${token} ${token}`, + "@research then @research and @research", + ), + ).toBe(`${token} then ${token} and @research`); + }); + it("retains omitted selections in their original order", () => { + const a = referenceToken("skill", "a", "A"); + const b = referenceToken("file", "b", "B"); + expect( + restoreReferenceLabels(`${a} then ${b}`, "Task without labels"), + ).toBe(`${a} ${b} Task without labels`); + }); +}); + +it("preserves known reference metadata while discovery has failed, including edits", () => { + const kept = { threadId: "one", title: "Known", agentName: "writer" }; + const result = reconcileConversationReferences( + referenceToken("conversation", "one", "Title one") + " edited", + [kept, { threadId: "deleted", title: "Deleted" }], + { enabled: false, maxReferences: 0, isLoading: false, isSuccess: false }, + "self", + ); + expect(result.references).toEqual([kept]); + expect(inlineReferences(result.text).map((ref) => ref.id)).toEqual(["one"]); +}); + +it("restores labels adjacent to Chinese prose", () => { + const token = referenceToken("skill", "research", "research"); + expect( + restoreReferenceLabels( + `请用${token},总结资料。`, + "请用@research,总结资料。", + ), + ).toBe(`请用${token},总结资料。`); +}); diff --git a/frontend/tests/unit/components/workspace/mentions/query.test.ts b/frontend/tests/unit/components/workspace/mentions/query.test.ts new file mode 100644 index 0000000000..87d3b29b53 --- /dev/null +++ b/frontend/tests/unit/components/workspace/mentions/query.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "@rstest/core"; + +import { getMentionQuery } from "@/components/workspace/mentions/query"; + +describe("composer mention query", () => { + it("replaces only the query at the caret, keeping both sides of the draft", () => { + const text = "请使用 @research 分析结果"; + const caret = text.indexOf(" 分析"); + const query = getMentionQuery(text, caret)!; + expect(query.query).toBe("research"); + }); + it("allows Unicode and filenames", () => { + expect(getMentionQuery("@报告.pdf", 7)?.query).toBe("报告.pdf"); + expect(getMentionQuery("(@报告", 4)?.query).toBe("报告"); + }); + it("does not activate in an email, URL, or completed text", () => { + for (const text of [ + "a@example.com", + "https://host/@name", + "@name ", + "@@name", + "path/@name", + ]) { + expect(getMentionQuery(text, text.length)).toBeNull(); + } + }); + it("keeps an unselected query as literal text", () => { + const text = "@unknown"; + expect(getMentionQuery(text, text.length)).toEqual({ + start: 0, + end: 8, + query: "unknown", + }); + expect(text).toBe("@unknown"); + }); +}); diff --git a/frontend/tests/unit/core/features/hooks.dom.test.tsx b/frontend/tests/unit/core/features/hooks.dom.test.tsx new file mode 100644 index 0000000000..28031ed6b1 --- /dev/null +++ b/frontend/tests/unit/core/features/hooks.dom.test.tsx @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, rs } from "@rstest/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; + +import { fetchConversationReferencesCapability } from "@/core/features/api"; +import { useConversationReferencesCapability } from "@/core/features/hooks"; + +rs.mock("@/core/features/api", () => ({ + fetchConversationReferencesCapability: rs.fn(), +})); +const fetchCapability = rs.mocked(fetchConversationReferencesCapability); +function renderCapability() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderHook(useConversationReferencesCapability, { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); +} +afterEach(() => { + cleanup(); + fetchCapability.mockReset(); +}); + +describe("conversation reference discovery", () => { + it("distinguishes an initial failure from a confirmed disable and supports retry", async () => { + fetchCapability.mockRejectedValueOnce(new Error("503 temporary")); + const { result } = renderCapability(); + expect(result.current.isLoading).toBe(true); + expect(result.current.isSuccess).toBe(false); + await waitFor(() => expect(result.current.error).toBeTruthy()); + expect(result.current.isLoading).toBe(false); + expect(result.current.isSuccess).toBe(false); + fetchCapability.mockResolvedValueOnce({ enabled: true, maxReferences: 4 }); + await result.current.refetch(); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current).toMatchObject({ + enabled: true, + maxReferences: 4, + error: null, + }); + }); + it("marks a successful disabled response as authoritative", async () => { + fetchCapability.mockResolvedValueOnce({ enabled: false, maxReferences: 0 }); + const { result } = renderCapability(); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current).toMatchObject({ + enabled: false, + isLoading: false, + error: null, + }); + }); + it("does not expose a cached enabled capability after a failed refresh", async () => { + fetchCapability.mockResolvedValueOnce({ enabled: true, maxReferences: 3 }); + const { result } = renderCapability(); + await waitFor(() => expect(result.current.enabled).toBe(true)); + fetchCapability.mockRejectedValueOnce(new Error("network unavailable")); + await result.current.refetch(); + await waitFor(() => expect(result.current.error).toBeTruthy()); + expect(result.current).toMatchObject({ + enabled: false, + isSuccess: false, + isLoading: false, + }); + }); +});