diff --git a/src/backend/bisheng/common/errcode/linsight.py b/src/backend/bisheng/common/errcode/linsight.py index adfad5304f..c1c4e654aa 100644 --- a/src/backend/bisheng/common/errcode/linsight.py +++ b/src/backend/bisheng/common/errcode/linsight.py @@ -7,6 +7,37 @@ class FileUploadError(BaseErrorCode): Msg: str = "Upload Failed" +# --------------------------------------------------------------------------- +# Task-mode FOLDER upload limits (11021–11023). Numbered next to FileUploadError +# (11020) because they belong to the same upload family. The frontend enforces +# the same three numbers for instant feedback; these are the server-side gate, +# so a user normally never sees them. +# --------------------------------------------------------------------------- + + +class LinsightFolderFileCountExceededError(BaseErrorCode): + Code: int = 11021 + Msg: str = "A folder upload may contain at most 100 files" + + +class LinsightFolderTotalSizeExceededError(BaseErrorCode): + Code: int = 11022 + Msg: str = "A folder upload may not exceed 500MB in total" + + +class LinsightFolderDepthExceededError(BaseErrorCode): + Code: int = 11023 + Msg: str = "Folder nesting may not exceed 10 levels" + + +# Per-file ceiling on the task-mode upload endpoint. It had none until folder +# upload made the gap material — 100 files arrive in one go, and a frontend-only +# check is a hint, not a limit. +class LinsightFileTooLargeError(BaseErrorCode): + Code: int = 11024 + Msg: str = "The file exceeds the upload size limit" + + # Your Idea has run out of uses, please use the new invite code to activate the Idea feature class LinsightUseUpError(BaseErrorCode): Code: int = 11030 diff --git a/src/backend/bisheng/common/services/config_service.py b/src/backend/bisheng/common/services/config_service.py index 0cf95a4bb1..dd7056210a 100644 --- a/src/backend/bisheng/common/services/config_service.py +++ b/src/backend/bisheng/common/services/config_service.py @@ -134,6 +134,168 @@ async def init_config(self): except Exception as e: logger.exception(e) await session.rollback() + return + + # The row exists, so the branch above will never run again on this + # install — which used to mean a setting shipped in a later release + # never reached the database at all. Backfill the missing keys (values + # already stored are left exactly as they are, so an operator's tuning + # survives every upgrade). + await self._backfill_missing_config(session, config, config_content, all_config_key) + + async def _backfill_missing_config( + self, session, config: list[Config], config_content: str, all_config_key: str + ) -> None: + """Add newly shipped config keys to the stored config; never overwrite. + + Best-effort by design: a failure here must not stop the service from + booting, because the code paths that read these settings all have their + own defaults. + """ + try: + db_row = next((conf for conf in config if conf.key == all_config_key), None) + if db_row is None or not db_row.value: + return + merged, added = self.merge_missing_config(config_content, db_row.value) + if not added: + return + # Re-parse before writing: a malformed merge must never replace a + # working configuration. + yaml.safe_load(merged) + db_row.value = merged + session.add(db_row) + await session.commit() + # The read path caches this row in redis for 100s; drop it so the new + # keys are visible immediately instead of after the TTL. + try: + get_redis_client_sync().delete("config:initdb_config") + except Exception: + logger.warning("config backfill: redis cache eviction failed; new keys apply within 100s") + logger.info(f"config backfill: added {len(added)} missing key(s) to initdb_config: {added}") + except Exception: + logger.exception("config backfill failed; stored configuration left untouched") + try: + await session.rollback() + except Exception: + logger.warning("config backfill: rollback failed") + + @staticmethod + def _extract_block(lines: list[str], key: str, indent: str) -> list[str]: + """Lines of ``key:`` and everything nested under it, plus the + comment lines directly above it. + + Text-level on purpose: a yaml.safe_load/dump round-trip would drop every + comment in the file, and those comments are what operators read in the + system-config page. + """ + prefix = f"{indent}{key}:" + for i, line in enumerate(lines): + if not (line == prefix.rstrip() or line.startswith(f"{prefix} ") or line.startswith(prefix)): + continue + # Walk up over the comment block that documents this key. + start = i + while start > 0 and lines[start - 1].strip().startswith("#"): + start -= 1 + # Walk down while lines are blank or nested deeper than this key. + end = i + 1 + while end < len(lines): + nxt = lines[end] + if not nxt.strip(): + end += 1 + continue + if len(nxt) - len(nxt.lstrip()) > len(indent): + end += 1 + continue + break + # Trailing blank lines belong to the separation, not to the block. + while end > i + 1 and not lines[end - 1].strip(): + end -= 1 + return lines[start:end] + return [] + + @staticmethod + def merge_missing_config(file_config: str, db_config: str) -> tuple[str, list[str]]: + """Add keys that exist in the shipped yaml but not in the stored config. + + Returns ``(merged_text, added_paths)``; ``added_paths`` is empty when the + stored config already covers the file. + + Why this exists: ``init_config`` only ever WRITES the yaml when the DB has + no ``initdb_config`` row at all, so on any environment that has been + installed once, a newly shipped setting never reaches the database. Values + then silently fall back to the Field defaults in ``settings.py`` — and only + for as long as nobody writes that section from the config page. A release + that adds ``linsight.max_model_turns`` is invisible to every existing + install; that is exactly how a 115-turn budget stayed in force after the + default had moved on. + + Rules, in order of importance: + 1. NEVER overwrite a value the DB already has — an operator's tuning + always wins, which is what makes running this on every boot safe. + 2. Keys the DB has and the file does not are kept untouched. + 3. New keys are carried over as RAW TEXT together with their comments. + 4. Two levels deep: a missing top-level section comes over whole + (nested content included); inside a section that already exists, only + its missing direct children are inserted. + """ + file_cfg = yaml.safe_load(file_config) or {} + db_cfg = yaml.safe_load(db_config) or {} + if not isinstance(file_cfg, dict) or not isinstance(db_cfg, dict): + return db_config, [] + + added: list[str] = [] + file_lines = file_config.split("\n") + out_lines = db_config.split("\n") + + for key, file_value in file_cfg.items(): + if key not in db_cfg: + block = ConfigService._extract_block(file_lines, key, "") + if block: + while out_lines and not out_lines[-1].strip(): + out_lines.pop() + out_lines.extend(["", "", *block]) + added.append(key) + continue + + # Section present on both sides — fill in only its missing children. + db_value = db_cfg.get(key) + if not isinstance(file_value, dict) or not isinstance(db_value, dict): + continue + missing = [sub for sub in file_value if sub not in db_value] + if not missing: + continue + + section = ConfigService._extract_block(file_lines, key, "") + if not section: + continue + # Re-locate the section on every key: earlier insertions shift the lines. + anchor = None + for i, line in enumerate(out_lines): + if line.startswith(f"{key}:"): + anchor = i + break + if anchor is None: + continue + end = anchor + 1 + while end < len(out_lines): + nxt = out_lines[end] + if not nxt.strip() or nxt.startswith((" ", "\t")): + end += 1 + continue + break + while end > anchor + 1 and not out_lines[end - 1].strip(): + end -= 1 + + insert: list[str] = [] + for sub in missing: + block = ConfigService._extract_block(section, sub, " ") + if block: + insert.extend(block) + added.append(f"{key}.{sub}") + if insert: + out_lines[end:end] = insert + + return "\n".join(out_lines), added @staticmethod def merge_old_config(new_config: str, old_db_config: list[Config], old_db_keys: dict[str, str]): diff --git a/src/backend/bisheng/core/config/settings.py b/src/backend/bisheng/core/config/settings.py index 988731801d..8333051813 100644 --- a/src/backend/bisheng/core/config/settings.py +++ b/src/backend/bisheng/core/config/settings.py @@ -377,7 +377,7 @@ class LinsightConf(BaseModel): default=100000, description="Maximum Tool Execution Historytoken, you need to summarize your history after" ) max_steps: int = Field( - default=500, + default=2500, description="LangGraph ``recursion_limit`` for a task graph — a runaway FUSE, not the business " "budget. It counts SUPER-STEPS, not model turns: one model turn costs ~4 super-steps " "(model -> tool-loop-breaker.after_model -> TodoList.after_model -> tools), so 500 ~= 115 turns. " @@ -385,13 +385,13 @@ class LinsightConf(BaseModel): "value automatically if it would trip before the turn budget.", ) max_model_turns: int = Field( - default=115, + default=600, description="Turn budget for the MAIN graph: how many model calls one task run may make before " "the soft-landing ladder forces it to wrap up. This is the real business gate (see max_steps). " "Reset on every ask_user resume, since the middleware instance is rebuilt with the agent.", ) max_model_turns_subagent: int = Field( - default=30, + default=120, description="Turn budget for the researcher subagent's own graph (counted separately — a subgraph " "runs its own Pregel loop with its own step counter).", ) diff --git a/src/backend/bisheng/initdb_config.yaml b/src/backend/bisheng/initdb_config.yaml index 772f7dded1..1716d4b55e 100644 --- a/src/backend/bisheng/initdb_config.yaml +++ b/src/backend/bisheng/initdb_config.yaml @@ -107,16 +107,22 @@ linsight: # (model → 工具循环熔断器 after_model → TodoList after_model → tools)。 # 另外「只产出 write_todos」的空转轮会退还预算(每个预算桶最多退 10 轮), # 所以实际模型调用可能超过 max_model_turns,保险丝要留出这部分余量: - # (115 + 10) × 4 + 20 = 520,取 600 留头。 + # (600 + 10) × 4 + 20 = 2460,取 2500 留头。 # 它是跑飞时的保险丝,真正的业务上限请调 max_model_turns。 # 若本值低于所需,后端会自动抬高并打 warning 日志。 - max_steps: 600 + max_steps: 2500 # 主图轮次预算:一次任务运行最多允许多少次模型调用,到顶前会提示模型收尾(软着陆)。 # ask_user 打断恢复后重新计数(中间件随 agent 重建)。 - max_model_turns: 115 + # 115 对真实的投标类任务远远不够:一次「9 份 PDF / 833 页 / 109 项参数逐项提取」的 + # 任务在 115 轮处只走完计划的前 3 步(页级索引与项目结构核实),一项参数都没提取就 + # 被迫收尾;而同类任务的工作量还会再翻一倍,故按最重的场景给到 600。 + # ⚠️ 代价是跑飞时烧得更久(最坏约 2460 个 super-step):超大任务仍建议拆成多次, + # 单次任务还受 checkpoint 写放大与上下文长度制约,堆高预算先撞到的往往是这两个。 + max_model_turns: 600 # 子代理(researcher)轮次预算。⚠️ 按「每次 task 调用」独立计数,不是整次任务共享: # 主图并行委派 2 个子代理时,每个各得完整一份,互不抢额度。 - max_model_turns_subagent: 30 + # 120:逐项提取/交叉校验这类重活由子代理承担,30 轮往往只够读完材料;与主图同比例放宽。 + max_model_turns_subagent: 120 # 距离预算耗尽还剩多少轮时开始提示模型收尾;最后 2 轮只保留写文件/导出工具, # 归零时不再提供任何工具,模型只能输出文本,图正常结束 soft_landing_turns: 8 diff --git a/src/backend/bisheng/linsight/api/endpoints/linsight.py b/src/backend/bisheng/linsight/api/endpoints/linsight.py index 74d7e08506..428682d279 100644 --- a/src/backend/bisheng/linsight/api/endpoints/linsight.py +++ b/src/backend/bisheng/linsight/api/endpoints/linsight.py @@ -15,6 +15,7 @@ from bisheng.api.v1.schemas import UnifiedResponseModel, resp_200 from bisheng.common.constants.enums.telemetry import ApplicationTypeEnum, BaseTelemetryTypeEnum from bisheng.common.dependencies.user_deps import UserPayload +from bisheng.common.errcode import BaseErrorCode from bisheng.common.errcode.http_error import NotFoundError, UnAuthorizedError from bisheng.common.errcode.linsight import ( FileUploadError, @@ -85,6 +86,10 @@ async def upload_file( "file_name": upload_result.get("original_filename"), "parsing_status": upload_result.get("parsing_status"), } + except BaseErrorCode as e: + # Typed business errors (e.g. the per-file size ceiling) keep their own + # code so the client can tell "too large" apart from a generic failure. + return e.return_resp_instance() except Exception as e: logger.error(f"Upload Failed: {e!s}") return FileUploadError.return_resp() @@ -199,6 +204,11 @@ async def event_generator(): "message_session": message_session_model.model_dump(), "linsight_session_version": linsight_session_version_model.model_dump(), } + except BaseErrorCode as e: + # Typed business errors (folder-upload limits, …) keep their own code so + # the client can show the right copy instead of a generic submit failure. + yield e.to_sse_event_instance() + return except Exception as e: yield LinsightQuestionError(exception=e).to_sse_event_instance() return @@ -243,25 +253,24 @@ async def start_execute( if session_version_model.status in [ SessionVersionStatusEnum.COMPLETED, SessionVersionStatusEnum.TERMINATED, - SessionVersionStatusEnum.IN_PROGRESS, ]: - # The Inspiration session version has been completed or is being executed and cannot be executed again + # A finished session must not be re-run. return LinsightSessionVersionRunningError.return_resp() - await MessageSessionDao.touch_session(session_version_model.session_id) + if session_version_model.status == SessionVersionStatusEnum.IN_PROGRESS: + # Already running — report success rather than an error. Submit now + # enqueues server-side, so by the time the client's start-execute lands + # the worker has often already picked the session up. Answering with an + # error there made the frontend's `.catch` mark a perfectly healthy task + # as failed (taskError + Stoped). "Is it running?" is what the caller + # actually wants to know, and the answer is yes. + logger.info(f"start-execute: session {linsight_session_version_id} already running; treating as no-op") + return resp_200(data=True, message="Ideas execution task is already running") - from bisheng.linsight.worker import LinsightQueue, encode_queue_item + await MessageSessionDao.touch_session(session_version_model.session_id) try: - redis_client = await get_redis_client() - queue = LinsightQueue("queue", namespace="linsight", redis=redis_client) - - await queue.put( - data=encode_queue_item( - linsight_session_version_id, - tenant_id=session_version_model.tenant_id, - ) - ) + await linsight_execute_utils.enqueue_session_for_execution(session_version_model) except Exception as e: logger.error(f"Failed to start the Ideas task: {e!s}") diff --git a/src/backend/bisheng/linsight/domain/schemas/linsight_schema.py b/src/backend/bisheng/linsight/domain/schemas/linsight_schema.py index 8fbad92121..c0257fc5f1 100644 --- a/src/backend/bisheng/linsight/domain/schemas/linsight_schema.py +++ b/src/backend/bisheng/linsight/domain/schemas/linsight_schema.py @@ -29,6 +29,16 @@ class SubmitFileSchema(BaseModel): # linsight parses it on-the-fly via TempFilePipeline at ingestion instead of # resolving a linsight Redis temp_info / pre-parsed markdown. file_url: str | None = Field(None, description="Daily-bucket raw file path (workstation upload)") + # Folder upload: this file's path relative to the folder the user dropped, + # e.g. ``年报/2024/Q1.xlsx``. None/empty means a plain single-file upload and + # keeps the historical flat ``uploads/`` layout. The workspace rebuilds + # the directory tree from this value, so it is sanitized segment-wise + # (``_safe_relpath``) before it ever reaches an object key. + relative_path: str | None = Field(None, description="Path relative to the uploaded folder root") + # Client-reported size, used ONLY for the batch-level folder-upload gate + # (all-or-nothing UX, mirroring knowledge-space ``FolderUploadItem.size``). + # The authoritative per-file size check runs at the upload endpoint. + size: int = Field(0, ge=0, description="File size in bytes (client-reported, batch gate only)") # Submit a problemSchema diff --git a/src/backend/bisheng/linsight/domain/services/workbench_impl.py b/src/backend/bisheng/linsight/domain/services/workbench_impl.py index 9ea0607de2..d5178768f3 100644 --- a/src/backend/bisheng/linsight/domain/services/workbench_impl.py +++ b/src/backend/bisheng/linsight/domain/services/workbench_impl.py @@ -17,6 +17,13 @@ from bisheng.common.constants.enums.telemetry import ApplicationTypeEnum, BaseTelemetryTypeEnum from bisheng.common.dependencies.user_deps import UserPayload from bisheng.common.errcode import BaseErrorCode +from bisheng.common.errcode.knowledge import KnowledgeFileNotSupportedError +from bisheng.common.errcode.linsight import ( + LinsightFileTooLargeError, + LinsightFolderDepthExceededError, + LinsightFolderFileCountExceededError, + LinsightFolderTotalSizeExceededError, +) from bisheng.common.schemas.telemetry.event_data_schema import NewMessageSessionEventData from bisheng.common.services import telemetry_service from bisheng.common.services.config_service import settings @@ -94,6 +101,17 @@ class LinsightWorkbenchImpl: # latency on every task start. _RAW_KEEP_MAX_BYTES = 50 * 1024 * 1024 + # ---- Folder upload (task mode) ------------------------------------------ + # A submission that carries any ``relative_path`` is a folder upload and is + # gated by these three numbers. The frontend enforces the same values before + # a single byte is uploaded (instant feedback, all-or-nothing); these are the + # authoritative server-side check, so they are normally never hit. + # Depth is counted in DIRECTORY segments, matching knowledge-space + # ``MAX_FOLDER_DEPTH``; the file name itself does not count as a level. + _FOLDER_MAX_FILES = 100 + _FOLDER_MAX_TOTAL_BYTES = 500 * 1024 * 1024 + _FOLDER_MAX_DEPTH = 10 + # ``mimetypes`` reads the SYSTEM mime database, which on a stock Linux image # (every deploy target, and CI) knows nothing about the OOXML types — xlsx / # docx / pptx all resolve to None there and every original lands in MinIO as @@ -321,6 +339,11 @@ async def submit_user_question( return message_session, linsight_session_version + except BaseErrorCode: + # A typed business error (e.g. the folder-upload limits) already carries + # the code the frontend branches on — re-wrapping it as a generic + # LinsightError would launder that away into "submit failed". + raise except Exception as e: logger.error(f"Failed to submit user question: {e!s}") raise cls.LinsightError(f"Failed to submit user question: {e!s}") @@ -403,6 +426,9 @@ async def _process_submitted_files( if file.parsing_status != "completed": raise cls.LinsightError(f"file {file.file_name} status is error: {file.parsing_status}") + # Folder upload: reject an over-sized batch before any byte is copied. + cls._validate_folder_upload(files) + # Daily-bucket files (unified-resource) are parsed on-the-fly; only the # linsight-pipeline files need a Redis temp_info lookup. linsight_files = [f for f in files if not f.file_url] @@ -477,12 +503,45 @@ async def _ingest_daily_file( return { "file_id": submit_file.file_id, "original_filename": submit_file.file_name, + "relative_path": submit_file.relative_path, "parsing_status": "failed", "valid": False, "error_message": f"file download failed: {e}", } file_name = submit_file.file_name or dl_name + + # 1.5) Route before parsing. Two of the three routes never had any business + # calling the ETL: a passthrough type has no loader (or a lossy one) and a + # ``.py`` would raise KnowledgeFileNotSupportedError purely so the except + # branch below could catch it and do what we can do directly — at the cost + # of a wasted round-trip and a logger.exception that reads like a real + # failure. ``unsupported`` short-circuits for the same reason; the pipeline + # would reject it on exactly the same extension check. + route = cls._ingest_route(file_name) + if route == "passthrough": + raw = await cls._read_local_bytes(local_path) + if raw is None: + return { + "file_id": submit_file.file_id, + "original_filename": file_name, + "relative_path": submit_file.relative_path, + "parsing_status": "failed", + "valid": False, + "error_message": "file bytes unavailable after download", + } + return await cls._finalize_passthrough(submit_file, file_name, chat_id, minio_client, raw, used_names) + if route == "unsupported": + return await cls._keep_original_in_workspace( + submit_file, + file_name, + chat_id, + minio_client, + local_path, + KnowledgeFileNotSupportedError(), + used_names, + ) + is_media = cls._is_media_filename(file_name) parse_started = time.monotonic() if is_media: @@ -582,6 +641,7 @@ async def _ingest_daily_file( entry: dict = { "file_id": submit_file.file_id, "original_filename": file_name, + "relative_path": submit_file.relative_path, "parsing_status": "completed", "valid": True, "markdown_file_path": formal_object, @@ -627,16 +687,23 @@ async def _keep_original_in_workspace( ) -> dict: """Parse-failure fallback: copy the raw original into the workspace. - Keeps the user-attached file visible to the agent (``ls``) under its real - name + extension (``uploads/.``) even though it couldn't be - converted to markdown. Marked ``valid=False`` so the attachment chip shows - a failed state. - - The original always reaches the formal bucket (the user can still download - what they uploaded), but it only enters the WORKSPACE when something there - could actually consume it — see ``_original_is_usable``. An mp3 that no - parser, no ``read_file`` and no code interpreter can open is pure cost: - storage, a confusing ``ls`` entry, and a wasted tool call. + Three outcomes, by what the workspace can actually do with the original: + + 1. **Text** (``_TEXT_LIKE_EXTS``) -> degrade to a passthrough SUCCESS. The + file reads perfectly well as itself, so the parse failure cost nothing + and reporting it as failed makes the chip cry wolf. This is what saves + a large csv, whose ``ExcelLoader`` hard-fails past 10k chars while the + csv sitting in the workspace is exactly what the user wanted analysed. + 2. **Binary with a consumer** (``_RAW_KEEP_EXTS`` / ``_IMAGE_EXTS``) -> + ``failed`` + ``valid=False``. A broken pdf really is broken: there is no + text view, and only the code interpreter can do anything with it. + 3. **Everything else** -> ``unsupported``, and it does not enter the + workspace at all. An mp3 that no parser, no ``read_file`` and no code + interpreter can open is pure cost: storage, a confusing ``ls`` entry, + and a wasted tool call. + + The original always reaches the formal bucket first, so in all three cases + the user can still download what they uploaded. """ def _read_bytes(path: str) -> bytes: @@ -644,6 +711,19 @@ def _read_bytes(path: str) -> bytes: return fh.read() raw = await asyncio.to_thread(_read_bytes, local_path) + + # Case 1. Same shape as a never-parsed passthrough file, so it goes through + # the same builder rather than a parallel one that would drift from it. + if cls._original_is_text(file_name): + logger.info( + "parse failed for {} but it is readable as text; using the original directly ({})", + file_name, + error, + ) + return await cls._finalize_passthrough( + submit_file, file_name, chat_id, minio_client, raw, used_names, degraded_from=error + ) + ext = os.path.splitext(file_name)[1] formal_object = f"linsight/{chat_id}/{submit_file.file_id}{ext}" await minio_client.put_object(bucket_name=minio_client.bucket, object_name=formal_object, file=raw) @@ -652,6 +732,7 @@ def _read_bytes(path: str) -> bytes: entry: dict = { "file_id": submit_file.file_id, "original_filename": file_name, + "relative_path": submit_file.relative_path, "parsing_status": "failed" if usable else "unsupported", "valid": False, "error_message": ( @@ -667,16 +748,172 @@ def _read_bytes(path: str) -> bytes: ) return entry - # Types whose raw bytes are still worth carrying into the workspace after a - # failed parse: text-like ones are directly readable via ``read_file``, and - # _RAW_KEEP_EXTS / _IMAGE_EXTS have a consumer (code interpreter, image block). - _TEXT_LIKE_EXTS = frozenset( + @classmethod + async def _finalize_passthrough( + cls, + submit_file: SubmitFileSchema, + file_name: str, + chat_id: str, + minio_client, + raw: bytes, + used_names: set[str] | None, + *, + degraded_from: Exception | None = None, + ) -> dict: + """Land a file in the workspace AS ITSELF, with no markdown view. + + Used for two situations that look different but produce the same result: + a type we never intended to parse (``_ingest_route`` -> ``passthrough``), + and a text file whose parse blew up but which is perfectly readable as-is + (``degraded_from``). Either way the outcome is a file the agent can + ``read_file`` and the code interpreter can open, so it is reported as a + SUCCESS — the historical ``failed`` / ``valid=False`` marking made the + chip cry wolf about a file that works. + + ``parsing_status`` stays ``"completed"`` on purpose. It is not "which + pipeline ran", it is "is ingestion finished / may this be submitted": the + frontend treats any other value as still-parsing (disabling send, polling + forever) and ``_process_submitted_files`` rejects the submission outright. + The pipeline that ran is recorded in the orthogonal ``ingest_mode`` field, + which older entries simply lack. + """ + formal_object = cls._formal_original_object(submit_file.file_id, chat_id, file_name) + await minio_client.put_object( + bucket_name=minio_client.bucket, + object_name=formal_object, + file=raw, + content_type=cls._content_type_for(file_name), + ) + + entry: dict = { + "file_id": submit_file.file_id, + "original_filename": file_name, + "relative_path": submit_file.relative_path, + "parsing_status": "completed", + "valid": True, + "ingest_mode": "passthrough", + # Not markdown, despite the name — see the key's docstring. It is the + # workspace SEED object, and three downstream consumers key off it + # (local prefetch, the workspace write below, the attachment drawer). + "markdown_file_path": formal_object, + "original_file_path": formal_object, + # Set BEFORE the workspace write: that path only ``setdefault``s a 0 + # for non-markdown attachments, so a real count has to be there first. + "line_count": cls._count_text_lines(raw), + "image_count": 0, + } + if degraded_from is not None: + entry["error_message"] = f"parse failed, original used directly: {degraded_from}" + + await cls._write_attachment_to_workspace(entry, chat_id, minio_client, as_markdown=False, used_names=used_names) + + # The raw track points at the same object as the reading track — that IS + # what passthrough means. Mirroring it is load-bearing rather than + # cosmetic: ``task_exec`` prefetches originals only for entries carrying + # both keys, and lands them under ``uploads/`` — the exact path the + # pointer block hands the model. Without the mirror the file would be + # prefetched flat at the task-dir root and the advertised path would not + # exist inside the code interpreter. + entry["raw_filename"] = entry.get("markdown_filename") + entry["raw_workspace_path"] = entry.get("workspace_path") + return entry + + @staticmethod + def _count_text_lines(raw: bytes) -> int: + """Line count for the pointer block, tolerant of unknown encodings. + + Decoding with ``errors="replace"`` matches what ``WorkspaceBackend`` will + do when the agent actually reads the file, so the advertised count and the + readable content agree. + """ + if not raw: + return 0 + text = raw.decode("utf-8", errors="replace") + return text.count("\n") + 1 + + # Types that go into the workspace AS THEMSELVES — no parse attempted, the + # original IS the workspace file. They are already in their final form: the + # agent reads them with ``read_file`` and the code interpreter opens them with + # pandas / json / whatever. Running them through the RAG pipeline would be a + # lossy round-trip at best and, for everything the knowledge parser has no + # loader for (.py, .json, …), a guaranteed exception whose only outcome is a + # misleading "parse failed" chip. + # + # ``.env`` and ``.pkl`` are deliberately absent: the first would carry + # credentials into the workspace and the model's context, the second is an + # arbitrary-code deserialization vector. + # + # Keep in sync with the frontend accept list, ``client/src/common/chatAccept.ts`` + # (TASK_MODE_DATA_ACCEPT) — that file is what decides whether a user can pick + # the file at all. + _PASSTHROUGH_TEXT_EXTS = frozenset( { "txt", "csv", "tsv", "md", "markdown", "json", "jsonl", "xml", "html", "htm", "log", "yaml", "yml", "ini", "conf", "toml", "sql", "py", "js", "ts", "sh", } ) # fmt: skip + # The overlap between "we could pass this through" and "the parser has a + # loader for it", resolved in favour of parsing. This exists so the tie-break + # is one greppable declaration instead of an implicit ordering between two + # frozensets. + # + # Only html/htm qualify: stripping tags is a real conversion, and raw markup + # is genuinely worse to read than the text inside it. + # + # Everything else that could be here is already in its final form, so parsing + # can only subtract: + # - csv -> ``ExcelLoader`` is a RAG chunker (slices every ``data_rows``, + # repeats the header per chunk, hard-fails past a size ceiling). A csv is + # plain text that ``read_file`` reads directly, with offset/limit for a + # cheap peek at the head — the "reading view" it would build is a reshuffle + # of something already readable, stored twice. + # - txt/md -> the loader decodes, the splitter chunks, and the ingest rejoins + # the chunks with a blank line, so the file the model reads is not quite the + # file the user uploaded. The only thing it buys is encoding detection, and + # ``WorkspaceBackend`` already falls back to cchardet on read. + _PARSE_WINS_EXTS = frozenset({"html", "htm"}) + + # Types whose raw bytes are still worth carrying into the workspace after a + # failed parse: text-like ones are directly readable via ``read_file``, and + # _RAW_KEEP_EXTS / _IMAGE_EXTS have a consumer (code interpreter, image block). + # Derived, not hand-maintained, so it can never drift from the routing sets. + _TEXT_LIKE_EXTS = _PASSTHROUGH_TEXT_EXTS + + @classmethod + def _ingest_route(cls, filename: str) -> str: + """Decide how a submitted file enters the workspace. + + Returns one of: + - ``"parse"``: run the knowledge ETL and write the markdown view + (plus, for ``_RAW_KEEP_EXTS``, the original beside it). + - ``"passthrough"``: skip the parser entirely; the original IS the + workspace file. + - ``"unsupported"``: no loader and no consumer — short-circuit instead + of burning an ETL round-trip on a guaranteed failure. + + The order below is an explicit table rather than set precedence, because + the sets genuinely overlap (csv is both passthrough-able and parseable) + and an implicit ordering is exactly the kind of thing that silently + inverts when someone adds a key to ``FileExtensionMap``. + """ + from bisheng.knowledge.rag.base_file_pipeline import FileExtensionMap + + ext = os.path.splitext(filename or "")[1].lower().lstrip(".") + if ext in cls._PASSTHROUGH_TEXT_EXTS and ext not in FileExtensionMap: + return "passthrough" + if ext in cls._PASSTHROUGH_TEXT_EXTS: + return "parse" if ext in cls._PARSE_WINS_EXTS else "passthrough" + if ext in FileExtensionMap: + return "parse" + return "unsupported" + + @classmethod + def _original_is_text(cls, filename: str) -> bool: + """Whether an unparsed original is readable as text via ``read_file``.""" + ext = os.path.splitext(filename or "")[1].lower().lstrip(".") + return ext in cls._TEXT_LIKE_EXTS + @classmethod def _original_is_usable(cls, filename: str) -> bool: """Whether an unparsed original has any consumer inside the workspace.""" @@ -698,7 +935,15 @@ async def _ingest_one_file( session is reused; the temp->formal copy is performed only once. """ # Idempotency: reuse a formal-bucket product produced earlier this session. - formal_object = cls._formal_markdown_object(submit_file.file_id, chat_id) + # A passthrough file has no markdown product, so its formal object keeps the + # real extension — the drawer builds a preview URL straight off this key and + # a ``.csv`` served as ``.md`` would be handed to the markdown renderer. + is_passthrough = bool(temp_info) and temp_info.get("ingest_mode") == "passthrough" + formal_object = ( + cls._formal_original_object(submit_file.file_id, chat_id, submit_file.file_name) + if is_passthrough + else cls._formal_markdown_object(submit_file.file_id, chat_id) + ) formal_exists = await minio_client.object_exists(bucket_name=minio_client.bucket, object_name=formal_object) if not formal_exists: @@ -712,6 +957,7 @@ async def _ingest_one_file( return { "file_id": submit_file.file_id, "original_filename": submit_file.file_name, + "relative_path": submit_file.relative_path, "parsing_status": "expired", "valid": False, "error_message": "file metadata expired, please re-upload", @@ -728,6 +974,9 @@ async def _ingest_one_file( entry: dict = dict(temp_info) if temp_info else {} entry["file_id"] = submit_file.file_id entry.setdefault("original_filename", submit_file.file_name) + # Always from THIS submission: temp_info is keyed by file_id and predates + # the folder pick, so a stale value there must not win. + entry["relative_path"] = submit_file.relative_path entry["parsing_status"] = "completed" entry["valid"] = True entry["markdown_file_path"] = formal_object @@ -748,6 +997,18 @@ async def _ingest_one_file( ) entry["original_file_path"] = formal_original + if is_passthrough: + # No markdown view exists: the original IS the workspace file, so it + # keeps its real extension and doubles as its own raw track. See + # ``_finalize_passthrough`` for why the mirror below is load-bearing. + entry["original_file_path"] = formal_object + await cls._write_attachment_to_workspace( + entry, chat_id, minio_client, as_markdown=False, used_names=used_names + ) + entry["raw_filename"] = entry.get("markdown_filename") + entry["raw_workspace_path"] = entry.get("workspace_path") + return entry + # Write parsed markdown into the workspace (uploads/.md). await cls._write_attachment_to_workspace(entry, chat_id, minio_client, used_names=used_names) return entry @@ -761,6 +1022,19 @@ def _formal_markdown_object(file_id: str, chat_id: str) -> str: """ return f"linsight/{chat_id}/{file_id}.md" + @staticmethod + def _formal_original_object(file_id: str, chat_id: str, filename: str) -> str: + """Formal-bucket key for a PASSTHROUGH file — same slot, real extension. + + A passthrough file has no markdown product, so reusing the ``.md`` key + above would serve a ``.csv`` under a ``.md`` name; the workspace panel + builds its preview URL straight off this key and would hand it to the + markdown renderer. Same stability property as the markdown key: one + object per (chat_id, file_id), so re-submission stays idempotent. + """ + ext = os.path.splitext(filename or "")[1].lower() + return f"linsight/{chat_id}/{file_id}{ext}" + @staticmethod async def _read_local_bytes(path: str) -> bytes | None: """Best-effort read of the transient local upload cache file. @@ -814,25 +1088,92 @@ def _safe_basename(original_filename: str) -> str: base = base.replace("..", "_").strip() # path traversal return base or "file" + @classmethod + def _safe_relpath(cls, relative_path: str | None) -> str: + """Sanitized DIRECTORY prefix for a folder-uploaded file, no trailing slash. + + ``年报/2024/Q1.xlsx`` -> ``年报/2024``. A plain upload (None/empty, or a + bare file name) yields ``""``, which keeps the historical flat + ``uploads/`` layout untouched. + + This is the counterpart ``_safe_basename`` cannot be: that one collapses + separators to ``_``, which is right for a file NAME and fatal for a folder + upload. Traversal is neutralized the way ``skill_store._safe_rel_path`` + does it — every ``.`` / ``..`` / empty segment is dropped, so a crafted + path cannot escape ``uploads/`` no matter what the client sends. Each + surviving segment then goes through ``_safe_basename``, so control + characters and stray separators are handled exactly as in file names. + + Depth is clamped defensively; ``_validate_folder_upload`` already rejects + an over-deep batch outright, so clamping here is belt-and-braces rather + than the user-facing rule. + """ + raw = (relative_path or "").strip().replace("\\", "/") + if not raw: + return "" + segments = [seg for seg in raw.split("/") if seg and seg not in (".", "..")] + # The trailing segment is the file name — it is sanitized separately by the + # caller and must never become a directory. + dir_segments = segments[:-1][: cls._FOLDER_MAX_DEPTH] + return "/".join(cls._safe_basename(seg) for seg in dir_segments) + + @classmethod + def _validate_folder_upload(cls, files: list[SubmitFileSchema]) -> None: + """Gate a folder upload on file count / total size / nesting depth. + + Only engages when at least one file carries a ``relative_path``; a plain + multi-file selection stays unbounded, exactly as before. + + Rejection is all-or-nothing on purpose. Silently truncating to the first + 100 files would hand the user a workspace that looks complete and is not — + the agent would summarize a partial folder without anyone noticing. + """ + if not any((f.relative_path or "").strip() for f in files): + return + + if len(files) > cls._FOLDER_MAX_FILES: + raise LinsightFolderFileCountExceededError() + + total_bytes = sum(max(f.size or 0, 0) for f in files) + if total_bytes > cls._FOLDER_MAX_TOTAL_BYTES: + raise LinsightFolderTotalSizeExceededError() + + for file in files: + raw = (file.relative_path or "").strip().replace("\\", "/") + if not raw: + continue + segments = [seg for seg in raw.split("/") if seg and seg not in (".", "..")] + # Directory levels only; the file name itself is not a level. + if len(segments) - 1 > cls._FOLDER_MAX_DEPTH: + raise LinsightFolderDepthExceededError() + @staticmethod - def _dedupe_workspace_name(filename: str, used_names: set[str] | None) -> str: - """Make ``filename`` unique within one submission (append ``-2``, ``-3`` …). + def _dedupe_workspace_name(rel_path: str, used_names: set[str] | None) -> str: + """Make ``rel_path`` unique within one submission (append ``-2``, ``-3`` …). - Without this, two distinct files sharing a base name would map to the same - ``uploads/`` key and the second would overwrite the first. + Without this, two distinct files sharing a workspace path would map to the + same ``uploads/`` key and the second would overwrite the first. + + The uniqueness namespace is the FULL relative path, so a folder upload + keeps ``报告/summary.md`` and ``附件/summary.md`` side by side — only a real + collision (same directory, same name) earns a suffix. The suffix always + lands on the file name, never on a directory segment, so a directory named + ``v1.2`` does not get its "extension" rewritten. """ if used_names is None: - return filename - if filename not in used_names: - used_names.add(filename) - return filename + return rel_path + if rel_path not in used_names: + used_names.add(rel_path) + return rel_path + dir_part, slash, filename = rel_path.rpartition("/") + prefix = f"{dir_part}{slash}" stem, dot, ext = filename.rpartition(".") base = stem if dot else filename suffix = f".{ext}" if dot else "" i = 2 - while f"{base}-{i}{suffix}" in used_names: + while f"{prefix}{base}-{i}{suffix}" in used_names: i += 1 - unique = f"{base}-{i}{suffix}" + unique = f"{prefix}{base}-{i}{suffix}" used_names.add(unique) return unique @@ -854,6 +1195,15 @@ async def _write_attachment_to_workspace( - **plus**, for ``_RAW_KEEP_EXTS`` that parsed fine, the ORIGINAL lands next to its markdown as ``uploads/.``. + Folder upload: when the entry carries a ``relative_path``, its directory + part is rebuilt underneath ``uploads/`` — ``年报/2024/Q1.xlsx`` lands at + ``uploads/年报/2024/Q1.md`` (+ the original beside it). The tree is the + user's own organization of the material and often carries meaning the file + names alone do not, so flattening it would lose information the agent needs. + Everything downstream is already nesting-safe: ``WorkspaceBackend`` + read/write/ls/glob/grep, the local write-through cache, and the cross-round + ``seed_workspace_from_previous`` copy. + The dual-track write is deliberate: markdown is the model's reading view (``read_file``, token-cheap), the original is the tool's data (``bisheng_code_interpreter`` with pandas / python-docx / fitz). A @@ -869,12 +1219,13 @@ async def _write_attachment_to_workspace( from bisheng.linsight.domain.services.workspace_backend import WORKSPACE_PREFIX safe = cls._safe_basename(entry["original_filename"]) + rel_dir = cls._safe_relpath(entry.get("relative_path")) if as_markdown: stem = safe.rsplit(".", 1)[0] if "." in safe else safe filename = f"{stem}.md" else: filename = safe - filename = cls._dedupe_workspace_name(filename, used_names) + filename = cls._dedupe_workspace_name(f"{rel_dir}/{filename}" if rel_dir else filename, used_names) rel_path = f"uploads/{filename}" object_key = f"{WORKSPACE_PREFIX}/{chat_id}/{rel_path}" @@ -921,6 +1272,11 @@ async def _write_raw_original_to_workspace( ``_ingest_daily_file``). Best-effort: a failure here must never sink an attachment whose markdown view already landed — the task stays usable, just without the precise-data track. + + The original lands in the SAME directory as its markdown view, folder + upload included (``uploads/年报/2024/Q1.xlsx`` next to ``…/Q1.md``), because + ``prepare_file_list`` hands the model the raw path relative to the code + interpreter's working directory and the local prefetch mirrors that layout. """ from bisheng.linsight.domain.services.workspace_backend import WORKSPACE_PREFIX @@ -934,7 +1290,8 @@ async def _write_raw_original_to_workspace( logger.warning("original {} is empty/missing; workspace keeps only the markdown view", original_object) return - raw_filename = cls._dedupe_workspace_name(safe_name, used_names) + rel_dir = cls._safe_relpath(entry.get("relative_path")) + raw_filename = cls._dedupe_workspace_name(f"{rel_dir}/{safe_name}" if rel_dir else safe_name, used_names) rel_path = f"uploads/{raw_filename}" content_type = cls._content_type_for(raw_filename) await minio_client.put_object( @@ -1067,6 +1424,12 @@ async def prepare_file_list( behind ARE announced, so the model does not meet an unreadable binary via ``ls`` and try to ``read_file`` it. + Folder upload changes the rendering, not the contract: pointers are grouped + under their directory, and past ``_FILE_LIST_MAX_ITEMS`` attachments the + block degrades to a per-directory summary that hands the model ``ls`` / + ``glob`` instead of a hundred pointer lines. A flat submission renders + exactly as it always did. + Args: has_code_interpreter: whether the sandboxed code interpreter is bound this run. Gates the "open the original with Python" guidance — @@ -1081,13 +1444,19 @@ async def prepare_file_list( if not session_version.files: return [] - items: list[str] = [] + # (directory, rendered pointer, display name) per announced attachment. + # The directory comes from the FOLDER-UPLOAD relative path, never from + # parsing ``workspace_path`` — the legacy ``/uploads//index.md`` + # fallback shape would otherwise read as a directory that does not exist. + records: list[tuple[str, str, str]] = [] has_raw = False + has_passthrough = False has_unparsed = False has_media = False for file in session_version.files: path = file.get("workspace_path") or f"/uploads/{file.get('file_id')}/index.md" name = file.get("original_filename", "") + rel_dir = cls._safe_relpath(file.get("relative_path")) if file.get("valid") is False: # Previously skipped outright, which left the model to discover the @@ -1101,10 +1470,18 @@ async def prepare_file_list( # never hears of it. Expired metadata stays skipped — there is # nothing to say beyond "re-upload", which the chip already says. if file.get("parsing_status") == "unsupported": - items.append(f"- name: {name}\n note: 该格式无法解析,也无法在工作区中读取,本次不可用") + records.append( + (rel_dir, f"- name: {name}\n note: 该格式无法解析,也无法在工作区中读取,本次不可用", name) + ) continue has_unparsed = True - items.append(f"- path: {path}\n name: {name}\n note: 解析失败,工作区只有原件(不可 read_file)") + records.append( + ( + rel_dir, + f"- path: {path}\n name: {name}\n note: 解析失败,工作区只有二进制原件(不可 read_file)", + name, + ) + ) continue item = "- path: {path}\n name: {name}\n lines: {lines}\n images: {images}".format( @@ -1115,7 +1492,15 @@ async def prepare_file_list( ) raw_path = file.get("raw_workspace_path") if raw_path: - has_raw = True + # A passthrough file mirrors its own path into the raw track, so + # ``path == raw``. Counting that as ``has_raw`` would trigger the + # dual-track wording ("raw holds the precise data, do not read_file + # it") about a file whose raw track is plain text the model should + # absolutely read. + if file.get("ingest_mode") == "passthrough": + has_passthrough = True + else: + has_raw = True # Rendered RELATIVE on purpose. The code interpreter resolves paths # against its working directory (the local task dir / sandbox root), # where the original lives at ``uploads/``; a leading slash @@ -1124,43 +1509,126 @@ async def prepare_file_list( if cls._is_media_filename(name): has_media = True item += "\n note: 音视频已 ASR 转写;path 为转写文本(.md),name 为原始上传文件名(非扩展名错误)" - items.append(item) + records.append((rel_dir, item, name)) - if not items: + if not records: return [] header_parts: list[str] = [] + has_folder = any(rel_dir for rel_dir, _, _ in records) + if has_folder: + header_parts.append( + "说明(文件夹):本次上传包含目录结构,下方按目录分组。目录层级是用户自己的组织方式," + "常常带有分类/时间/版本等含义,理解与产出时请保留这一结构。" + '需要进一步定位时用 ls("/uploads/<目录>") 或 glob(如 "/uploads/**/*.xlsx"),不要假设文件不存在。\n' + ) if has_media: header_parts.append( "说明(音视频): 中 name 为 .mp3/.mp4 等原始上传名," "path 为平台 ASR 转写后的 .md 文本视图。请 read_file(path) 获取语音/视频内容;" "这不是扩展名标注错误或误命名,勿在回复中声称「实际是文本文件」。\n" ) - if has_raw or has_unparsed: - # Say it once, at the top, instead of repeating per item: markdown is - # the reading view, the original is the data. Without this the model - # reads the flattened table and "eyeballs" numbers it could compute. + # One ``说明:`` paragraph assembled from independent clauses, rather than a + # block per file kind. The kinds co-occur freely (a dual-track xlsx, a + # passthrough csv and a broken pdf in one submission), and a paragraph each + # would both bloat the prompt and contradict itself — the dual-track wording + # ends in "do not read_file the original", which is exactly wrong for a + # passthrough file whose original is the text. + has_binary_original = has_raw or has_unparsed + detail_parts: list[str] = [] + if has_raw: + detail_parts.append( + "path 指向可直接 read_file 的文本视图;raw 指向同名原件" + "(表格/文档的精确数据、单元格、样式、页面结构都在原件里)。" + ) + if has_passthrough: + detail_parts.append( + "部分条目的 path 与 raw 指向同一个文本原件(.csv/.py/.json 等)——" + "它本身就是最终格式,read_file 与代码工具都可直接使用,不必再去找「解析后的版本」。" + ) + if has_unparsed: + detail_parts.append("标注「解析失败」的条目在工作区只有二进制原件,不可 read_file。") + if has_binary_original: if has_code_interpreter: - header_parts.append( - "说明:path 指向可直接 read_file 的文本视图;raw 指向同名原件" - "(表格/文档的精确数据、单元格、样式、页面结构都在原件里)。" - "需要精确数值或做数据分析时,用 bisheng_code_interpreter 读 raw 原件" - "(Excel 用 pandas/openpyxl,Word 用 python-docx,PDF 用 fitz),不要 read_file 原件。" - "raw 是相对当前工作目录的路径,在代码里直接用该相对路径打开。\n" + detail_parts.append( + "需要精确数值或做数据分析时,用 bisheng_code_interpreter 读原件" + "(Excel 用 pandas/openpyxl,Word 用 python-docx,PDF 用 fitz),不要 read_file 二进制原件。" + "raw 是相对当前工作目录的路径,在代码里直接用该相对路径打开。" ) else: - # No code interpreter this run: the original is unusable, so do not - # send the model chasing it. Say what it CAN do instead. - header_parts.append( - "说明:path 指向可直接 read_file 的文本视图;raw 是原始二进制文件," - "本次没有可用的代码执行工具,无法读取原件——请基于文本视图作答," - "并在结论中说明受原件格式限制的部分。不要对 raw 路径调用 read_file。\n" + # No code interpreter this run: the binary original is unusable, so + # do not send the model chasing it. Say what it CAN do instead. + detail_parts.append( + "本次没有可用的代码执行工具,无法读取二进制原件——请基于文本视图作答," + "并在结论中说明受原件格式限制的部分。" ) + elif has_passthrough and has_code_interpreter: + detail_parts.append("raw 是相对当前工作目录的路径,在代码里直接用该相对路径打开。") + if detail_parts: + header_parts.append("说明:" + "".join(detail_parts) + "\n") header = "".join(header_parts) - block = "\n" + header + "\n".join(items) + "\n" + if len(records) > cls._FILE_LIST_MAX_ITEMS: + body = cls._render_upload_dir_summary(records) + else: + body = cls._render_upload_pointers(records, grouped=has_folder) + + block = "\n" + header + body + "\n" return [block] + # Past this many attachments a per-file pointer list stops being an index and + # starts being noise that crowds out the user's actual question. A folder + # upload is capped at _FOLDER_MAX_FILES, so the summary mode below is what a + # large folder actually renders as. + _FILE_LIST_MAX_ITEMS = 40 + + @staticmethod + def _group_upload_records(records: list[tuple[str, str, str]]) -> dict[str, list[tuple[str, str]]]: + """Group ``(dir, pointer, name)`` by directory, preserving first-seen order.""" + grouped: dict[str, list[tuple[str, str]]] = {} + for rel_dir, text, name in records: + grouped.setdefault(rel_dir, []).append((text, name)) + return grouped + + @classmethod + def _render_upload_pointers(cls, records: list[tuple[str, str, str]], *, grouped: bool) -> str: + """Full per-file pointer list, optionally grouped under directory headings.""" + if not grouped: + return "\n".join(text for _, text, _ in records) + + chunks: list[str] = [] + for rel_dir, entries in cls._group_upload_records(records).items(): + heading = f"[目录] /uploads/{rel_dir}/" if rel_dir else "[目录] /uploads/" + chunks.append(heading + "\n" + "\n".join(text for text, _ in entries)) + return "\n".join(chunks) + + @classmethod + def _render_upload_dir_summary(cls, records: list[tuple[str, str, str]]) -> str: + """Directory-level summary used when the per-file list would be too long. + + Emits one entry per directory with a file count and an extension + breakdown, and points the model at ``ls`` / ``glob`` for the actual names. + The pointer block is an index, not the data — the model reads bodies on + demand either way, so a summary loses nothing but the token cost. + """ + lines: list[str] = [] + total = 0 + for rel_dir, entries in cls._group_upload_records(records).items(): + total += len(entries) + ext_counts: dict[str, int] = {} + for _, name in entries: + ext = os.path.splitext(name or "")[1].lower().lstrip(".") or "无扩展名" + ext_counts[ext] = ext_counts.get(ext, 0) + 1 + types = "、".join(f"{ext}×{count}" for ext, count in sorted(ext_counts.items(), key=lambda kv: -kv[1])) + path = f"/uploads/{rel_dir}/" if rel_dir else "/uploads/" + lines.append(f"- dir: {path}\n files: {len(entries)}\n types: {types}") + lines.append( + f"共 {total} 个文件,数量较多,此处只列目录概览。" + '用 ls("/uploads/<目录>") 列出具体文件名,用 glob(如 "/uploads/**/*.xlsx")按类型定位,' + "再对需要的文件 read_file。" + ) + return "\n".join(lines) + @classmethod async def prepare_knowledge_list(cls, knowledge_list: list[KnowledgeRead]) -> list[str]: """Render each available KB as a clean, readable prompt line that clearly @@ -1311,10 +1779,20 @@ async def upload_file(cls, file: UploadFile) -> dict: Returns: File Information Dictionary """ - # Generate file information - file_id = uuid.uuid4().hex[:8] # Buat8Bit Unique FileID + from bisheng.knowledge.domain.upload_file_size import get_max_upload_bytes + # url Code decode The file name original_filename = unquote(file.filename) + + # Per-file ceiling, shared with the knowledge upload path so documents and + # media keep their (configurable) separate limits. Task mode had no + # server-side size check at all — tolerable while files arrived one at a + # time, not once a folder upload sends a hundred in one go. + if file.size is not None and file.size > get_max_upload_bytes(original_filename): + raise LinsightFileTooLargeError() + + # Generate file information + file_id = uuid.uuid4().hex[:8] # Buat8Bit Unique FileID file_extension = original_filename.split(".")[-1] if "." in original_filename else "" unique_filename = f"{file_id}.{file_extension}" @@ -1400,6 +1878,37 @@ async def _parse_file( from bisheng.api.v1.schemas import FileProcessBase from bisheng.knowledge.rag.temp_file_pipeline import TempFilePipeline + # Passthrough types never reach the parser. This branch matters more + # here than on the daily path: a parse failure is reported to the + # follow-up input as ``failed``, and that input DELETES the attachment + # from the box on sight — so without this a ``.py`` could not even be + # submitted, let alone used. + if cls._ingest_route(original_filename) == "passthrough": + raw_bytes = await cls._read_local_bytes(file_path) + if raw_bytes is not None: + minio_client = await get_minio_storage() + tmp_key = f"{file_id}{os.path.splitext(original_filename)[1].lower()}" + await minio_client.put_object_tmp(tmp_key, raw_bytes) + return { + "file_id": file_id, + "original_filename": original_filename, + "parsing_status": "completed", + "ingest_mode": "passthrough", + "parse_type": "passthrough", + "markdown_filename": tmp_key, + "markdown_file_path": tmp_key, + "markdown_file_md5": await async_calculate_md5(raw_bytes), + # Counted here, where the bytes are already in hand. The + # workspace write only ``setdefault``s a 0 for non-markdown + # attachments, so a real count has to arrive before it. + "line_count": cls._count_text_lines(raw_bytes), + # Deliberately no ``original_file_path``: ingest promotes + # this same key to the formal bucket, and setting it would + # make it store a second copy of identical bytes. + } + # Unreadable local cache file: fall through to the parser so the + # failure is reported through the one existing path. + file_rule = FileProcessBase( knowledge_id=0, separator=["\n\n", "\n"], diff --git a/src/backend/bisheng/linsight/domain/services/workspace_backend.py b/src/backend/bisheng/linsight/domain/services/workspace_backend.py index 6d7f4aee79..806b863d22 100644 --- a/src/backend/bisheng/linsight/domain/services/workspace_backend.py +++ b/src/backend/bisheng/linsight/domain/services/workspace_backend.py @@ -534,7 +534,11 @@ def ls(self, path: str = "") -> LsResult: rel_prefix = self._ws_rel(path) if path else "" object_prefix = f"{WORKSPACE_PREFIX}/{self.svid}/" if rel_prefix: - object_prefix += rel_prefix + # Terminate the prefix at a directory boundary. Without the slash, + # ``ls("/uploads/年报")`` also matches a sibling ``年报备份/`` — harmless + # when uploads were flat, wrong as soon as a folder upload puts real + # sibling directories in there. + object_prefix += rel_prefix.rstrip("/") + "/" entries: list[FileInfo] = [] key_prefix = f"{WORKSPACE_PREFIX}/{self.svid}/" try: @@ -600,9 +604,41 @@ def edit( return EditResult(path="/" + rel, occurrences=occurrences) # -- glob --------------------------------------------------------------- - def glob(self, pattern: str, path: str | None = None) -> GlobResult: + @staticmethod + def _glob_patterns(pattern: str) -> tuple[str, ...]: + """The spellings of ``pattern`` that should all mean the same thing. + + Two mismatches to absorb, both of which used to silently return zero + matches for patterns we ourselves tell the model to write: + + - **Leading slash.** ``ls`` reports ``/uploads/a/b.csv`` and every tool + argument in the prompt is written absolute, but matching happens against + the workspace-RELATIVE key (``uploads/a/b.csv``). ``fnmatch`` is literal + about that first character, so ``/uploads/**/*.xlsx`` — the exact + spelling in the folder-upload guidance — matched nothing. + - **``**`` spanning zero directories.** ``fnmatch`` has no ``**``; it + treats it as a plain ``*`` that happens to cross ``/``. So + ``uploads/**/*.csv`` demands at least one intermediate directory and + skips ``uploads/top.csv`` — surprising for a pattern whose whole point + is "anywhere under uploads". Collapsing ``**/`` gives that case its + own candidate. + """ + pat = (pattern or "").strip().lstrip("/") + candidates = [pat] + if "**/" in pat: + candidates.append(pat.replace("**/", "")) + return tuple(dict.fromkeys(c for c in candidates if c)) + + @classmethod + def _glob_matches(cls, rel_in_ws: str, pattern: str) -> bool: import fnmatch + return any( + fnmatch.fnmatch(rel_in_ws, pat) or fnmatch.fnmatch(os.path.basename(rel_in_ws), pat) + for pat in cls._glob_patterns(pattern) + ) + + def glob(self, pattern: str, path: str | None = None) -> GlobResult: base = self._ws_rel(path) if path else "" ls_res = self.ls(base) if ls_res.error is not None: @@ -612,7 +648,7 @@ def glob(self, pattern: str, path: str | None = None) -> GlobResult: for entry in ls_res.entries or []: rel = entry["path"] rel_in_ws = rel[len(prefix) :] if rel.startswith(prefix) else rel.lstrip("/") - if fnmatch.fnmatch(rel_in_ws, pattern) or fnmatch.fnmatch(os.path.basename(rel_in_ws), pattern): + if self._glob_matches(rel_in_ws, pattern): matches.append(entry) return GlobResult(matches=matches) @@ -626,14 +662,15 @@ def grep(self, pattern: str, path: str | None = None, glob: str | None = None) - return GrepResult(error=ls_res.error) prefix = f"/{WORKSPACE_PREFIX}/{self.svid}/" matches: list = [] - import fnmatch skipped_large = 0 skipped_binary = 0 for entry in ls_res.entries or []: full = entry["path"] rel_in_ws = full[len(prefix) :] if full.startswith(prefix) else full.lstrip("/") - if glob and not (fnmatch.fnmatch(rel_in_ws, glob) or fnmatch.fnmatch(os.path.basename(rel_in_ws), glob)): + # Same spelling tolerance as ``glob`` — an absolute filter must not + # silently narrow the scan to nothing. + if glob and not self._glob_matches(rel_in_ws, glob): continue # ``ls`` already reports the object size; use it to avoid downloading a # multi-MB original (uploads/ carries them since the dual-track write) diff --git a/src/backend/bisheng/linsight/domain/task_exec.py b/src/backend/bisheng/linsight/domain/task_exec.py index 4946e343e2..524c3b5411 100644 --- a/src/backend/bisheng/linsight/domain/task_exec.py +++ b/src/backend/bisheng/linsight/domain/task_exec.py @@ -710,11 +710,19 @@ async def _init_file_directory(self, session_model: LinsightSessionVersion) -> s # still parsing) must be SKIPPED — not crash task startup. (The agent reads # uploaded sources through the WorkspaceBackend ``uploads/`` keys anyway, so # this local prefetch is best-effort cache warming, not the access path.) - downloadable = [f for f in session_model.files if isinstance(f, dict) and f.get("markdown_file_path")] - skipped = len(session_model.files) - len(downloadable) + entries = [f for f in session_model.files if isinstance(f, dict) and f.get("markdown_file_path")] + skipped = len(session_model.files) - len(entries) if skipped: logger.warning(f"{skipped} uploaded file(s) without markdown_file_path skipped for local prefetch") + # A passthrough file has no separate markdown view — its seed object and + # its original are the same bytes. It is fetched by the raw track below, + # which lands it under ``uploads/`` where the pointer block says it is; + # letting it through here as well would ALSO drop a flat copy at the task + # root, and the code interpreter's file list (``os.walk``) would show the + # same file twice under two different paths. + downloadable = [f for f in entries if f.get("ingest_mode") != "passthrough"] + # Concurrent downloads download_tasks = [self._download_file(file_info, file_dir) for file_info in downloadable] @@ -732,7 +740,9 @@ async def _init_file_directory(self, session_model: LinsightSessionVersion) -> s # lands here is invisible to pandas / python-docx / fitz — which is the # whole point of keeping it. Best-effort: a miss costs the precise-data # track, never the task. - raw_files = [f for f in downloadable if f.get("raw_filename") and f.get("original_file_path")] + # Selected from ``entries``, not ``downloadable``: passthrough files are + # excluded from the markdown track precisely so they arrive here. + raw_files = [f for f in entries if f.get("raw_filename") and f.get("original_file_path")] if raw_files: raw_results = await asyncio.gather( *[self._download_raw_original(f, file_dir) for f in raw_files], return_exceptions=True @@ -756,6 +766,10 @@ async def _download_file(self, file_info: dict, target_dir: str) -> str: raise ValueError("file entry missing markdown_file_path") file_name = file_info.get("markdown_filename", os.path.basename(object_name)) file_path = os.path.join(target_dir, file_name) + # ``markdown_filename`` carries the folder-upload sub-path (``年报/2024/Q1.md``) + # for files that came in as part of a directory, so the parent dirs have to + # exist before the write. os.makedirs on the flat case is a no-op. + os.makedirs(os.path.dirname(file_path), exist_ok=True) minio_client = await get_minio_storage() try: file_url = await minio_client.get_share_link(object_name, clear_host=False) diff --git a/src/backend/bisheng/linsight/domain/utils.py b/src/backend/bisheng/linsight/domain/utils.py index 39c2cffa10..baf1b5884e 100644 --- a/src/backend/bisheng/linsight/domain/utils.py +++ b/src/backend/bisheng/linsight/domain/utils.py @@ -596,6 +596,33 @@ async def check_and_terminate_incomplete_tasks(node_id: str) -> None: return +async def enqueue_session_for_execution(session_model: LinsightSessionVersion) -> None: + """Hand a task-mode session to the Linsight worker queue. + + Single entry point for enqueueing, shared by the unified submit path and the + ``/workbench/start-execute`` endpoint. + + Enqueueing used to be the CLIENT's job: submit created the session, streamed a + handoff event, and the browser then called start-execute. Anything that cut + the stream before that second call — a refresh, a closed tab, a proxy timeout + — left the session parked at NOT_STARTED with nothing to pick it up. That is + not hypothetical: a task with 12 attachments spent minutes parsing them + INSIDE the submit request, the user gave up waiting, and the session sat in + the table untouched (the conversation lost its task row too, so even the + task-mode badge disappeared). Submitting now enqueues server-side, so the + task runs whether or not the client is still listening. + + Enqueueing twice is harmless: the executor re-reads the session and bails via + ``_is_session_in_progress`` when it is already running, so the client's + start-execute stays a safe no-op / late retry. + """ + from bisheng.linsight.worker import LinsightQueue, encode_queue_item + + redis_client = await get_redis_client() + queue = LinsightQueue("queue", namespace="linsight", redis=redis_client) + await queue.put(data=encode_queue_item(session_model.id, tenant_id=session_model.tenant_id)) + + async def persist_task_turn_message(session_model: LinsightSessionVersion) -> ChatMessage: """F035 Track J (TJ-3): upsert the task turn into the unified conversation. diff --git a/src/backend/bisheng/workflow/nodes/report/docx_replace.py b/src/backend/bisheng/workflow/nodes/report/docx_replace.py index 78ad9ee695..74b6ab6305 100644 --- a/src/backend/bisheng/workflow/nodes/report/docx_replace.py +++ b/src/backend/bisheng/workflow/nodes/report/docx_replace.py @@ -1,19 +1,21 @@ +import copy import re from io import BytesIO -from typing import List, Dict, Any, IO +from typing import IO, Any from docx import Document from docx.enum.style import WD_STYLE_TYPE from docx.oxml import OxmlElement -from docx.shared import Pt, Inches, RGBColor +from docx.shared import Inches, Pt, RGBColor from docx.table import _Cell from docx.text.paragraph import Paragraph +from docx.text.run import Run # Separator between the human-readable node name and the lookup key inside a # placeholder: ``{{display name|node_id.field}}``. The name is a snapshot taken # when the variable was inserted -- it exists purely so the template is readable # and is never used to resolve values (release-contract INV-8). -PLACEHOLDER_DISPLAY_SEPARATOR = '|' +PLACEHOLDER_DISPLAY_SEPARATOR = "|" def normalize_placeholder_key(raw: str) -> str: @@ -41,7 +43,7 @@ class DocxReplacer: def __init__(self, template_path: str | IO[bytes]): self.template_path = template_path self.doc = Document(template_path) - self.placeholder_pattern = re.compile(r'\{\{([^}]+)\}\}') + self.placeholder_pattern = re.compile(r"\{\{([^}]+)\}\}") self._init_style() def check_style(self, style_name: str, **kwargs): @@ -63,7 +65,7 @@ def _init_style(self): self.check_style("Heading 5", size=152400) self.check_style("Heading 6", bold=True) - def replace_and_save(self, variables: Dict[str, List[Dict[str, Any]]], output_path: str): + def replace_and_save(self, variables: dict[str, list[dict[str, Any]]], output_path: str): """ Replace the placeholders and save the document. @@ -84,14 +86,14 @@ def replace_and_save(self, variables: Dict[str, List[Dict[str, Any]]], output_pa self.doc.save(output_path) - def _process_table(self, table, variables: Dict[str, List[Dict[str, Any]]]): + def _process_table(self, table, variables: dict[str, list[dict[str, Any]]]): for row in table.rows: for cell in row.cells: self._process_paragraphs(cell.paragraphs, variables) for nested_table in cell.tables: self._process_table(nested_table, variables) - def _process_paragraphs(self, paragraphs: List[Paragraph], variables: Dict[str, List[Dict[str, Any]]]): + def _process_paragraphs(self, paragraphs: list[Paragraph], variables: dict[str, list[dict[str, Any]]]): i = 0 while i < len(paragraphs): paragraph = paragraphs[i] @@ -99,21 +101,138 @@ def _process_paragraphs(self, paragraphs: List[Paragraph], variables: Dict[str, matches = list(self.placeholder_pattern.finditer(text)) if matches: - insert_index = self._get_paragraph_index(paragraph) - self._replace_paragraph_placeholders(paragraph, matches, variables, insert_index) + # Plain-text values are written into the run that holds the + # placeholder, so the author's underline / font / size survive and + # the paragraph itself (style, numbering, borders) is never + # rebuilt. Anything block-level still takes the rebuild path. + if not ( + self._placeholders_are_inline(matches, variables) + and self._replace_inline_placeholders(paragraph, variables) + ): + insert_index = self._get_paragraph_index(paragraph) + self._replace_paragraph_placeholders(paragraph, matches, variables, insert_index) i += 1 + def _placeholders_are_inline( + self, + matches: list[re.Match], + variables: dict[str, list[dict[str, Any]]], + ) -> bool: + """Whether every placeholder in this paragraph resolves to plain text. + + A table, image or heading cannot live inside a run — those paragraphs + have to be split apart, which is what the rebuild path does. + """ + for match in matches: + items = variables.get(match.group(1)) + if items is None: + # Unresolved placeholder: left verbatim either way. + continue + if any(item.get("type") != "text" for item in items): + return False + return True + + def _replace_inline_placeholders( + self, + paragraph: Paragraph, + variables: dict[str, list[dict[str, Any]]], + ) -> bool: + """Rewrite text placeholders in place, one run at a time. + + Returns False when the placeholders cannot be located in the run text — + a placeholder split across a hyperlink, say — so the caller can fall + back to rebuilding the paragraph. + """ + runs = paragraph.runs + if not runs: + return False + + runs_text = "".join(run.text for run in runs) + matches = list(self.placeholder_pattern.finditer(runs_text)) + if not matches: + return False + + # Right to left: rewriting a later span never shifts an earlier offset. + for match in reversed(matches): + items = variables.get(match.group(1)) + if items is None: + continue + self._replace_run_span(paragraph, match.start(), match.end(), items) + return True + + def _replace_run_span( + self, + paragraph: Paragraph, + start: int, + end: int, + items: list[dict[str, Any]], + ): + """Replace the characters [start, end) of a paragraph's run text.""" + spans = [] + offset = 0 + for run in paragraph.runs: + spans.append((offset, offset + len(run.text), run)) + offset += len(run.text) + + touched = [ + (run_start, run_end, run) for run_start, run_end, run in spans if run_end > start and run_start < end + ] + if not touched: + return + + first_start, _, first_run = touched[0] + last_start, _, last_run = touched[-1] + prefix = first_run.text[: start - first_start] + suffix = last_run.text[end - last_start :] + + # Snapshot the placeholder run's formatting before writing to it: extra + # value items and the trailing text should inherit what the author put on + # the placeholder, not what the first item asks for. + template_element = copy.deepcopy(first_run._element) + + for _run_start, _run_end, run in touched[1:]: + run.text = "" + + text_items = items or [{"content": ""}] + first_run.text = prefix + text_items[0].get("content", "") + self._apply_run_format(first_run, text_items[0]) + + anchor = first_run._element + for item in text_items[1:]: + anchor = self._insert_run_after(paragraph, anchor, template_element, item.get("content", ""), item) + + if suffix: + if last_run is not first_run: + last_run.text = suffix + else: + self._insert_run_after(paragraph, anchor, template_element, suffix, {}) + + def _insert_run_after( + self, + paragraph: Paragraph, + anchor_element, + template_element, + text: str, + format_data: dict[str, Any], + ): + new_element = copy.deepcopy(template_element) + anchor_element.addnext(new_element) + run = Run(new_element, paragraph) + run.text = text + self._apply_run_format(run, format_data) + return new_element + def _get_paragraph_index(self, paragraph: Paragraph) -> int: parent = paragraph._element.getparent() return parent.index(paragraph._element) def _replace_paragraph_placeholders( - self, - paragraph: Paragraph, - matches: List[re.Match], - variables: Dict[str, List[Dict[str, Any]]], - insert_index: int + self, + paragraph: Paragraph, + matches: list[re.Match], + variables: dict[str, list[dict[str, Any]]], + insert_index: int, ): parent = paragraph._element.getparent() text = paragraph.text @@ -126,33 +245,17 @@ def _replace_paragraph_placeholders( start, end = match.span() if start > last_end: - segments.append({ - 'type': 'text_segment', - 'content': text[last_end:start], - 'paragraph': paragraph - }) + segments.append({"type": "text_segment", "content": text[last_end:start], "paragraph": paragraph}) if var_name in variables: - segments.append({ - 'type': 'variable', - 'content': variables[var_name], - 'paragraph': paragraph - }) + segments.append({"type": "variable", "content": variables[var_name], "paragraph": paragraph}) else: - segments.append({ - 'type': 'text_segment', - 'content': match.group(0), - 'paragraph': paragraph - }) + segments.append({"type": "text_segment", "content": match.group(0), "paragraph": paragraph}) last_end = end if last_end < len(text): - segments.append({ - 'type': 'text_segment', - 'content': text[last_end:], - 'paragraph': paragraph - }) + segments.append({"type": "text_segment", "content": text[last_end:], "paragraph": paragraph}) original_format = self._extract_paragraph_format(paragraph) original_run_format = self._extract_run_format(paragraph.runs[0] if paragraph.runs else None) @@ -163,113 +266,111 @@ def _replace_paragraph_placeholders( current_paragraph = None for segment in segments: - if segment['type'] == 'text_segment': + if segment["type"] == "text_segment": if current_paragraph is None: - current_paragraph = self._insert_paragraph_at_index( - parent, current_insert_index, original_format - ) + current_paragraph = self._insert_paragraph_at_index(parent, current_insert_index, original_format) current_insert_index += 1 - run = current_paragraph.add_run(segment['content']) + run = current_paragraph.add_run(segment["content"]) self._apply_run_format(run, original_run_format) - elif segment['type'] == 'variable': - for item in segment['content']: - item_type = item.get('type') + elif segment["type"] == "variable": + for item in segment["content"]: + item_type = item.get("type") - if item_type == 'text': + if item_type == "text": if current_paragraph is None: current_paragraph = self._insert_paragraph_at_index( parent, current_insert_index, original_format ) current_insert_index += 1 - run = current_paragraph.add_run(item['content']) + run = current_paragraph.add_run(item["content"]) self._apply_run_format(run, item) - elif item_type in ['table', 'image', 'heading']: + elif item_type in ["table", "image", "heading"]: if current_paragraph is not None and current_paragraph.text.strip(): current_paragraph = None - if item_type == 'table': + if item_type == "table": self._insert_table_at_index(parent, current_insert_index, item) - elif item_type == 'image': + elif item_type == "image": self._insert_image_at_index(parent, current_insert_index, item, original_format) - elif item_type == 'heading': + elif item_type == "heading": self._insert_heading_at_index(parent, current_insert_index, item) current_insert_index += 1 current_paragraph = None - def _extract_paragraph_format(self, paragraph: Paragraph) -> Dict[str, Any]: + def _extract_paragraph_format(self, paragraph: Paragraph) -> dict[str, Any]: return { - 'alignment': paragraph.alignment, - 'left_indent': paragraph.paragraph_format.left_indent, - 'right_indent': paragraph.paragraph_format.right_indent, - 'first_line_indent': paragraph.paragraph_format.first_line_indent, - 'space_before': paragraph.paragraph_format.space_before, - 'space_after': paragraph.paragraph_format.space_after, - 'line_spacing': paragraph.paragraph_format.line_spacing, + "alignment": paragraph.alignment, + "left_indent": paragraph.paragraph_format.left_indent, + "right_indent": paragraph.paragraph_format.right_indent, + "first_line_indent": paragraph.paragraph_format.first_line_indent, + "space_before": paragraph.paragraph_format.space_before, + "space_after": paragraph.paragraph_format.space_after, + "line_spacing": paragraph.paragraph_format.line_spacing, } - def _extract_run_format(self, run) -> Dict[str, Any]: + def _extract_run_format(self, run) -> dict[str, Any]: if run is None: return {} return { - 'bold': run.bold, - 'italic': run.italic, - 'underline': run.underline, - 'font_name': run.font.name, - 'font_size': run.font.size, - 'font_color': run.font.color.rgb if run.font.color.rgb else None, + "bold": run.bold, + "italic": run.italic, + "underline": run.underline, + "font_name": run.font.name, + "font_size": run.font.size, + "font_color": run.font.color.rgb if run.font.color.rgb else None, } - def _insert_paragraph_at_index(self, parent, index: int, format_dict: Dict[str, Any]) -> Paragraph: - p_element = OxmlElement('w:p') + def _insert_paragraph_at_index(self, parent, index: int, format_dict: dict[str, Any]) -> Paragraph: + p_element = OxmlElement("w:p") parent.insert(index, p_element) paragraph = Paragraph(p_element, self.doc) # 应用格式 - if format_dict.get('alignment') is not None: - paragraph.alignment = format_dict['alignment'] - if format_dict.get('left_indent') is not None: - paragraph.paragraph_format.left_indent = format_dict['left_indent'] - if format_dict.get('right_indent') is not None: - paragraph.paragraph_format.right_indent = format_dict['right_indent'] - if format_dict.get('first_line_indent') is not None: - paragraph.paragraph_format.first_line_indent = format_dict['first_line_indent'] - if format_dict.get('space_before') is not None: - paragraph.paragraph_format.space_before = format_dict['space_before'] - if format_dict.get('space_after') is not None: - paragraph.paragraph_format.space_after = format_dict['space_after'] - if format_dict.get('line_spacing') is not None: - paragraph.paragraph_format.line_spacing = format_dict['line_spacing'] + if format_dict.get("alignment") is not None: + paragraph.alignment = format_dict["alignment"] + if format_dict.get("left_indent") is not None: + paragraph.paragraph_format.left_indent = format_dict["left_indent"] + if format_dict.get("right_indent") is not None: + paragraph.paragraph_format.right_indent = format_dict["right_indent"] + if format_dict.get("first_line_indent") is not None: + paragraph.paragraph_format.first_line_indent = format_dict["first_line_indent"] + if format_dict.get("space_before") is not None: + paragraph.paragraph_format.space_before = format_dict["space_before"] + if format_dict.get("space_after") is not None: + paragraph.paragraph_format.space_after = format_dict["space_after"] + if format_dict.get("line_spacing") is not None: + paragraph.paragraph_format.line_spacing = format_dict["line_spacing"] return paragraph - def _apply_run_format(self, run, format_data: Dict[str, Any]): - if format_data.get('bold'): + def _apply_run_format(self, run, format_data: dict[str, Any]): + if format_data.get("bold"): run.bold = True - if format_data.get('italic'): + if format_data.get("italic"): run.italic = True - if format_data.get('underline'): + if format_data.get("underline"): run.underline = True - if format_data.get('font_size'): - if isinstance(format_data['font_size'], int): - run.font.size = Pt(format_data['font_size']) + if format_data.get("font_size"): + if isinstance(format_data["font_size"], int): + run.font.size = Pt(format_data["font_size"]) else: - run.font.size = format_data['font_size'] - if format_data.get('font_name'): - run.font.name = format_data['font_name'] - if format_data.get('color'): - if isinstance(format_data['color'], tuple) and len(format_data['color']) == 3: - run.font.color.rgb = RGBColor(*format_data['color']) - if format_data.get('font_color'): - run.font.color.rgb = format_data['font_color'] - - def _insert_table_at_index(self, parent, index: int, item: Dict[str, Any]): - data = item['content'] + run.font.size = format_data["font_size"] + if format_data.get("font_name"): + run.font.name = format_data["font_name"] + if format_data.get("color"): + if isinstance(format_data["color"], tuple) and len(format_data["color"]) == 3: + run.font.color.rgb = RGBColor(*format_data["color"]) + if format_data.get("font_color"): + run.font.color.rgb = format_data["font_color"] + + def _insert_table_at_index(self, parent, index: int, item: dict[str, Any]): + data = item["content"] rows = len(data) cols = len(data[0]) if rows > 0 else 0 @@ -278,8 +379,8 @@ def _insert_table_at_index(self, parent, index: int, item: Dict[str, Any]): table = self.doc.add_table(rows=0, cols=cols) - if item.get('style'): - table.style = item['style'] + if item.get("style"): + table.style = item["style"] for row_data in data: row = table.add_row() @@ -307,100 +408,104 @@ def _fill_cell(self, cell: _Cell, cell_content: Any): if cell.paragraphs: default_paragraph = cell.paragraphs[0] for run in default_paragraph.runs: - run.text = '' + run.text = "" else: default_paragraph = cell.add_paragraph() if isinstance(cell_content, dict): - if 'type' not in cell_content or 'content' not in cell_content: + if "type" not in cell_content or "content" not in cell_content: raise ValueError( - f"The cell element must contain the `type` and `content` fields, but got:{cell_content}") + f"The cell element must contain the `type` and `content` fields, but got:{cell_content}" + ) cell_content = [cell_content] elif isinstance(cell_content, list): for element in cell_content: - if not isinstance(element, dict) or 'type' not in element or 'content' not in element: + if not isinstance(element, dict) or "type" not in element or "content" not in element: raise ValueError( - f"The cell element must contain the `type` and `content` fields, but got:{element}") + f"The cell element must contain the `type` and `content` fields, but got:{element}" + ) else: raise ValueError(f"Not supported data type:{type(cell_content)}") current_paragraph = default_paragraph for element in cell_content: - element_type = element['type'] - if element_type == 'text': - run = current_paragraph.add_run(element['content']) + element_type = element["type"] + if element_type == "text": + run = current_paragraph.add_run(element["content"]) self._apply_run_format(run, element) - elif element_type == 'image': + elif element_type == "image": if current_paragraph.text.strip(): current_paragraph = cell.add_paragraph() self._add_image_to_paragraph(current_paragraph, element) current_paragraph = cell.add_paragraph() - elif element_type == 'paragraph': + elif element_type == "paragraph": current_paragraph = cell.add_paragraph() - if element.get('alignment'): - current_paragraph.alignment = element['alignment'] + if element.get("alignment"): + current_paragraph.alignment = element["alignment"] - if isinstance(element['content'], str): - run = current_paragraph.add_run(element['content']) + if isinstance(element["content"], str): + run = current_paragraph.add_run(element["content"]) self._apply_run_format(run, element) - elif isinstance(element['content'], list): - for text_item in element['content']: - if not isinstance(text_item, dict) or 'type' not in text_item: - raise ValueError(f"Paragraph content elements must include a `type` field; got:{text_item}") - if text_item['type'] == 'text': - run = current_paragraph.add_run(text_item['content']) + elif isinstance(element["content"], list): + for text_item in element["content"]: + if not isinstance(text_item, dict) or "type" not in text_item: + raise ValueError( + f"Paragraph content elements must include a `type` field; got:{text_item}" + ) + if text_item["type"] == "text": + run = current_paragraph.add_run(text_item["content"]) self._apply_run_format(run, text_item) - if element.get('alignment'): + if element.get("alignment"): for cell_paragraph in cell.paragraphs: - cell_paragraph.alignment = element['alignment'] + cell_paragraph.alignment = element["alignment"] - def _add_image_to_paragraph(self, paragraph: Paragraph, image_data: Dict[str, Any]): + def _add_image_to_paragraph(self, paragraph: Paragraph, image_data: dict[str, Any]): run = paragraph.add_run() try: - width = Inches(image_data.get('width', 2)) - height = Inches(image_data.get('height')) if image_data.get('height') else None + width = Inches(image_data.get("width", 2)) + height = Inches(image_data.get("height")) if image_data.get("height") else None - if isinstance(image_data['content'], str): + if isinstance(image_data["content"], str): # local file path if height: - run.add_picture(image_data['content'], width=width, height=height) + run.add_picture(image_data["content"], width=width, height=height) else: - run.add_picture(image_data['content'], width=width) - elif isinstance(image_data['content'], bytes): + run.add_picture(image_data["content"], width=width) + elif isinstance(image_data["content"], bytes): # bytes data - image_stream = BytesIO(image_data['content']) + image_stream = BytesIO(image_data["content"]) if height: run.add_picture(image_stream, width=width, height=height) else: run.add_picture(image_stream, width=width) except Exception as e: - paragraph.add_run(f"Image add failed: {str(e)}]") + paragraph.add_run(f"Image add failed: {e!s}]") # set alignment - if image_data.get('alignment'): - paragraph.alignment = image_data['alignment'] + if image_data.get("alignment"): + paragraph.alignment = image_data["alignment"] - def _insert_image_at_index(self, parent, index: int, item: Dict[str, Any], paragraph_format: Dict[str, Any]): + def _insert_image_at_index(self, parent, index: int, item: dict[str, Any], paragraph_format: dict[str, Any]): paragraph = self._insert_paragraph_at_index(parent, index, paragraph_format) self._add_image_to_paragraph(paragraph, item) - def _insert_heading_at_index(self, parent, index: int, item: Dict[str, Any]): - p_element = OxmlElement('w:p') + def _insert_heading_at_index(self, parent, index: int, item: dict[str, Any]): + p_element = OxmlElement("w:p") parent.insert(index, p_element) paragraph = Paragraph(p_element, self.doc) - level = item.get('level', 1) - paragraph.style = f'Heading {level}' + level = item.get("level", 1) + paragraph.style = f"Heading {level}" - run = paragraph.add_run(item['content']) + run = paragraph.add_run(item["content"]) self._apply_run_format(run, item) - def extract_variables(self) -> List[str]: + def extract_variables(self) -> list[str]: variables = [] seen = set() @@ -437,11 +542,11 @@ def extract_variables(self) -> List[str]: return variables - def _extract_vars_from_text(self, text: str) -> List[str]: + def _extract_vars_from_text(self, text: str) -> list[str]: matches = self.placeholder_pattern.findall(text) return matches - def _extract_vars_from_table(self, table) -> List[str]: + def _extract_vars_from_table(self, table) -> list[str]: variables = [] seen = set() diff --git a/src/backend/bisheng/workstation/domain/services/chat_service.py b/src/backend/bisheng/workstation/domain/services/chat_service.py index 20c7128c60..77cea4cf5a 100644 --- a/src/backend/bisheng/workstation/domain/services/chat_service.py +++ b/src/backend/bisheng/workstation/domain/services/chat_service.py @@ -1274,6 +1274,18 @@ async def _agent_initialize_chat(data: APIChatCompletion, login_user: UserPayloa # parse failure. Mirrors the task-mode failure card's transient/terminal split. _TRANSIENT_PARSE_ERRORS = frozenset({ErrorType.RATE_LIMIT, ErrorType.NETWORK_TIMEOUT, ErrorType.SERVICE_UNAVAILABLE}) +# Extracted document text is hard-cut at ``maxTokens`` CHARACTERS. Silently +# handing the model a prefix makes it read the fragment as the whole document: +# a 1072-page tender truncated to 15k chars still "answers" questions about +# tables that live on page 400, complete with fabricated page citations. Naming +# the cut is what lets the model say "I only have the first N characters". +_TRUNCATION_NOTICE = ( + "\n\n[TRUNCATED] Only the first {shown} of {total} characters of the uploaded file(s) appear above; " + "the remainder was NOT provided to you. Do not state or infer anything about the omitted part, and do " + "not cite page, section or table numbers that are not visible in the text above. If answering needs " + "the full document, say so plainly instead of guessing." +) + async def _extract_doc_text(filepath: str, filename: str, invoke_user_id: int) -> str: """Extract one attachment's text, turning a parser failure into a domain error. @@ -1331,7 +1343,10 @@ async def _process_agent_files(data: APIChatCompletion, model_info, login_user, annotated_valid = await _annotate_agent_files_with_video_covers(valid_files, downloaded_files) merged_files = _merge_agent_file_covers(data.files, valid_files, annotated_valid) max_token = getattr(ws_config, "maxTokens", 15000) or 15000 - file_context = "\n".join(doc_results)[:max_token] + joined_docs = "\n".join(doc_results) + file_context = joined_docs[:max_token] + if len(joined_docs) > max_token: + file_context += _TRUNCATION_NOTICE.format(shown=len(file_context), total=len(joined_docs)) logger.info( f"[process_agent_files] docs={len(doc_results)} visuals={len(visual_results)}" f" file_context_len={len(file_context)} max_token={max_token}" @@ -1680,12 +1695,13 @@ def persist_interrupted_turn(reason: str) -> None: "{cur_date}", datetime.now().strftime("%Y-%m-%d %H:%M:%S"), ) - # Citation-rule backstop: inject only when knowledge/citation tools are in play - # and the admin prompt doesn't already carry the rules (the default does), so - # existing configs keep citations and updated prompts aren't duplicated. - has_citation_tool = knowledge_bases_info or any( - isinstance(tool, DailyChatCitationToolWrapper) for tool in langchain_tools - ) + # No citation-rule backstop here on purpose: the default daily-chat + # system prompt (platform locales, `chatConfig.systemPrompt2`) already + # carries the full marker spec — source-id format, the private-use + # delimiters and the "never invent an id" rule — making it a superset + # of CITATION_PROMPT_RULES. A backstop was declared here once but the + # flag was never read, so it never ran; injecting it now would only + # duplicate rules the prompt already states. llm_messages = list(history) + [HumanMessage(content=content_payload)] logger.info( @@ -2160,6 +2176,27 @@ async def _task_mode_stream_completion(request: Request, data: APIChatCompletion submit_obj, login_user, display_files=data.files ) + # Enqueue HERE, not from the browser after it receives the handoff below. + # submit_user_question parses every attachment inline, so this request can run + # for minutes on a multi-file task; a user who stops waiting (refresh, closed + # tab, proxy timeout) never sends the follow-up start-execute, and the session + # is stranded at NOT_STARTED with no one to pick it up. Enqueueing server-side + # decouples "the task runs" from "the client is still listening". The client's + # start-execute remains as a late retry and is safe to arrive after this: the + # executor rejects re-entry on an already-running session. + from bisheng.linsight.domain import utils as linsight_execute_utils + + try: + await linsight_execute_utils.enqueue_session_for_execution(session_version) + await linsight_execute_utils.persist_task_turn_message(session_version) + except Exception: + # Keep streaming the handoff: the client's start-execute is the fallback + # path, and failing the whole submit here would lose the question too. + logger.exception( + f"[TASK_SUBMIT] server-side enqueue failed chat_id={session_version.session_id} " + f"svid={session_version.id}; relying on client start-execute" + ) + # Generate the conversation title straight from the user's question (task # mode has no "round complete" moment to hang it on). Reuse the daily-mode # title helper with the daily chat model (data.model) rather than the @@ -2288,6 +2325,10 @@ def _to_linsight_submit(data: APIChatCompletion): file_name=item.get("file_name") or item.get("filename") or item.get("name") or "", parsing_status=item.get("parsing_status") or "completed", file_url=item.get("filepath") or item.get("file_url"), + # Folder upload: the tree the user dropped is rebuilt inside the + # task workspace from this. Absent on a plain single-file pick. + relative_path=item.get("relative_path") or None, + size=int(item.get("size") or 0), ) ) submit_files = submit_files or None diff --git a/src/backend/bisheng/workstation/domain/services/workstation_service.py b/src/backend/bisheng/workstation/domain/services/workstation_service.py index 12fdd642e6..f7c114f1ba 100644 --- a/src/backend/bisheng/workstation/domain/services/workstation_service.py +++ b/src/backend/bisheng/workstation/domain/services/workstation_service.py @@ -1287,6 +1287,19 @@ async def queryChunksFromDB( logger.exception(f"queryChunksFromDB error: {exc}") return [], None, failures + @staticmethod + def _attachment_display_name(file_item: dict) -> str: + """Best-effort display name for one attachment row. + + Daily uploads and task-mode ingests disagree on the key, so try each in + turn rather than assuming one shape. + """ + for key in ("file_name", "filename", "name", "original_filename"): + value = file_item.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + @classmethod async def get_chat_history(cls, chat_id: str, size: int = 4, max_tokens: int | None = None): """Build LLM-consumable chat history, backward compatible with both @@ -1322,10 +1335,16 @@ async def get_chat_history(cls, chat_id: str, size: int = 4, max_tokens: int | N for one in messages: raw = one.message or "" if one.category == MessageCategory.QUESTION.value: - # Try new JSON format: {"query": "..."} + # Try new JSON format: {"query": "...", "files": [...]} + attachments: list[dict] = [] try: parsed = json.loads(raw) - content = parsed.get("query", raw) if isinstance(parsed, dict) else raw + if isinstance(parsed, dict): + content = parsed.get("query", raw) + files = parsed.get("files") + attachments = [f for f in files if isinstance(f, dict)] if isinstance(files, list) else [] + else: + content = raw except (json.JSONDecodeError, TypeError): content = raw # Legacy rows may carry a rewritten prompt in `extra.prompt`. @@ -1335,6 +1354,15 @@ async def get_chat_history(cls, chat_id: str, size: int = 4, max_tokens: int | N content = extra["prompt"] except (json.JSONDecodeError, TypeError): pass + # Name the attachments of past turns. Their extracted text is NOT + # replayed (it was already truncated into that turn's prompt, and + # replaying it would blow the history budget), but a model that + # cannot see the file at all tends to answer from its own summary + # of an earlier turn — inventing page citations it never read. + # Knowing a file was attached is what lets it say so instead. + names = [n for n in (cls._attachment_display_name(f) for f in attachments) if n] + if names: + content = f"{content}\n[attachments] {', '.join(names)}" chat_history.append(HumanMessage(content=content)) elif one.category == MessageCategory.AGENT_ANSWER.value: diff --git a/src/backend/bisheng_langchain/gpts/tools/code_interpreter/base_executor.py b/src/backend/bisheng_langchain/gpts/tools/code_interpreter/base_executor.py index eff2bd1ebc..a2554b618a 100644 --- a/src/backend/bisheng_langchain/gpts/tools/code_interpreter/base_executor.py +++ b/src/backend/bisheng_langchain/gpts/tools/code_interpreter/base_executor.py @@ -45,6 +45,49 @@ "`skills//SKILL.md` or `uploads/`." ) +# --- Workspace escape ------------------------------------------------------ +# The local executor is a subprocess on the SHARED backend host, not a sandbox: +# a script can read anything the service account can. That is how a daily-chat +# turn once answered from `/root/.cache/bisheng/bisheng/` — the global download +# cache, where EVERY user's uploads pile up under a flat sha256 name. Reading +# another tenant's document is not a feature we want to keep, so these patterns +# reject the run outright (unlike the advisories above, which only annotate). +# +# Matching an ACCESS VERB rather than a bare string literal is deliberate: code +# may legitimately mention such a path in prose it prints back to the user. +_FS_ACCESS_VERBS = ( + "open|walk|scandir|listdir|glob|iglob|rglob|Path|PosixPath|copy|copy2|copyfile|copytree|" + "move|rename|remove|unlink|rmtree|stat|lstat|getsize|exists|isfile|isdir|read_text|read_bytes" +) +# Host directories that are never part of a workspace. The workspace's own +# absolute-looking zones (/output /scratch /skills /uploads) are handled by +# ``absolute_path_advisory`` and are deliberately absent here. +_HOST_ROOTS = "root|home|etc|proc|sys|boot|opt|srv|usr|var|app|data|mnt|media" +# Group 2 captures the whole literal so the caller can tell "somewhere on the +# host" from "my own working dir, spelled absolutely" — linsight hands the model +# host paths of its own workspace, so those must stay legal. +_HOST_PATH_ACCESS_RE = re.compile( + rf"""\b(?:{_FS_ACCESS_VERBS})\s*\([^)\n]{{0,120}}?(['"])(/(?:{_HOST_ROOTS})[^'"\n]*)\1""" +) +# ``expanduser("~")`` / ``Path.home()`` resolve to the SERVICE account's home. +# A model hunting for "the file I was given" reaches for ``~`` early. +_HOME_EXPANSION_RE = re.compile(r"""expanduser\s*\(\s*['"]~|Path\s*\.\s*home\s*\(""") +# A scan rooted at ``/`` walks the entire container. +_ROOT_SCAN_RE = re.compile(r"""\b(?:walk|glob|iglob|scandir|listdir)\s*\(\s*['"]/['"]""") + +WORKSPACE_ESCAPE_NOTICE = ( + "[SYSTEM NOTICE] This run was REJECTED and nothing was executed: the code reaches OUTSIDE " + "the working directory — a host path (/root, /etc, /app, /home, ...), the expanded home " + "directory (`~`), or a scan rooted at `/`. Those locations are shared infrastructure that " + "may hold other users' data; they are not yours to read.\n" + "Your current working directory IS your workspace. Use RELATIVE paths only: " + "`uploads/` for provided sources, `output/` for deliverables, `scratch/` " + "for intermediates.\n" + "If what you are looking for is not under the working directory, it was NOT provided to you " + "on this turn. Say so plainly and ask for it — do not search the filesystem for it, and do " + "not answer from memory of an earlier turn as if you had re-read the file." +) + # Delivery zones of the executor working dir. ``output/`` is the ONLY zone the # linsight harvester (``get_final_result_file``) treats as deliverables; # ``scratch/`` is explicitly intermediate. @@ -139,6 +182,37 @@ def absolute_path_advisory(code: str) -> str: notices += ABSOLUTE_PROVISIONED_PATH_NOTICE return notices + def workspace_escape_guard(self, code: str) -> str: + """Rejection notice when ``code`` reaches outside the working dir; "" if clean. + + Unlike ``absolute_path_advisory`` (annotates a completed run), a hit here + means the run must NOT happen: the local executor shares a filesystem with + the backend service, so a read of ``/root/.cache/...`` returns other users' + uploaded documents. Blocking after the fact would be pointless — the data + would already be in the model's context. + + Two deliberate exemptions keep legitimate code running: + + - Matching is VERB-anchored, so prose stays legal: a script may print + "nothing under /root/.cache" without being rejected; only an actual + ``open`` / ``os.walk`` / ``glob`` against a host root trips it. + - A literal under ``local_sync_path`` is this run's OWN workspace written + absolutely. ``path_namespace_rules`` shows the model exactly such host + paths, so rejecting them would break the thing we told it to expect. + """ + if not code: + return "" + work_dir = (self.local_sync_path or "").rstrip("/") + for match in _HOST_PATH_ACCESS_RE.finditer(code): + path = match.group(2) + if work_dir and (path == work_dir or path.startswith(f"{work_dir}/")): + continue + return WORKSPACE_ESCAPE_NOTICE + # `~` and `/` are never the workspace, so no exemption applies. + if _HOME_EXPANSION_RE.search(code) or _ROOT_SCAN_RE.search(code): + return WORKSPACE_ESCAPE_NOTICE + return "" + @staticmethod def relocation_advisory(moved: list[tuple[str, str]]) -> str: """Corrective notice listing ``(old_rel, new_rel)`` relocations into diff --git a/src/backend/bisheng_langchain/gpts/tools/code_interpreter/local_executor.py b/src/backend/bisheng_langchain/gpts/tools/code_interpreter/local_executor.py index f53551cbb8..0903e377c3 100644 --- a/src/backend/bisheng_langchain/gpts/tools/code_interpreter/local_executor.py +++ b/src/backend/bisheng_langchain/gpts/tools/code_interpreter/local_executor.py @@ -159,6 +159,29 @@ def _cmd(lang): return "powershell" raise NotImplementedError(f"{lang} not recognized in code execution") + @staticmethod + def _child_env(work_dir: str | None) -> dict[str, str]: + """Environment for the executed script. + + ``HOME`` is pointed at the working directory. Otherwise ``expanduser('~')`` + resolves to the SERVICE account's home (``/root`` in the shipped image), + which is shared by every user's runs and holds the download cache of their + uploads — and reaching for ``~`` is exactly what a model does when it goes + looking for "the file I was given". Paired with + ``workspace_escape_guard``: the guard rejects the obvious spellings, this + makes the ones it cannot see (``os.environ['HOME']``, a library resolving + ``~`` internally) land inside the workspace instead of on the host. + + ``MPLCONFIGDIR`` is pinned to matplotlib's current cache dir FIRST, because + moving ``HOME`` would otherwise send matplotlib to a fresh, empty config + dir and make it rebuild the font cache on every single run. + """ + env = os.environ.copy() + if work_dir: + env.setdefault("MPLCONFIGDIR", matplotlib.get_cachedir()) + env["HOME"] = work_dir + return env + @classmethod def _execute_code( cls, @@ -178,6 +201,7 @@ def _execute_code( proc = subprocess.Popen( cmd, cwd=work_dir, + env=cls._child_env(work_dir), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -381,6 +405,13 @@ def _tail(logs: str, limit: int = MAX_FAILURE_LOG_CHARS) -> str: def run(self, code: str) -> Any: original_code = code + # Checked BEFORE anything executes: this executor is a subprocess on the + # shared backend host, so by the time an escaping read has run, another + # user's document is already in the model's context. + escape_notice = self.workspace_escape_guard(original_code) + if escape_notice: + logger.warning("code interpreter: rejected a run that reaches outside the working directory") + return {"exitcode": 1, "log": escape_notice, "file_list": []} code_blocks = self.extract_code(code) logs_all = "" all_file_list = [] diff --git a/src/backend/test/common/test_config_backfill.py b/src/backend/test/common/test_config_backfill.py new file mode 100644 index 0000000000..d26cf41862 --- /dev/null +++ b/src/backend/test/common/test_config_backfill.py @@ -0,0 +1,154 @@ +"""Newly shipped config keys must reach environments that are already installed. + +``init_config`` writes ``initdb_config.yaml`` into the database only when the row +is absent. On any install that has booted once the row exists, so every setting +added in a later release stopped at the file and never reached the DB — the code +then silently ran on the ``settings.py`` Field default instead. That is how a +task-mode turn budget stayed at 115 on every existing deployment after the +default had already moved on, and it is invisible: the config page shows the +stored (old) config, which simply has no such key. + +``merge_missing_config`` closes that gap on every boot. Its contract is narrow on +purpose, and these tests pin the parts that make it safe to run unattended: + + * a value already in the DB is NEVER touched (operator tuning outranks ours); + * keys only the DB has are preserved (hand-added settings survive); + * comments come across with the key, because that text is the documentation + operators read in the system-config page; + * nothing to add → byte-identical output, so repeated boots do not churn the + row or evict its cache. +""" + +from __future__ import annotations + +import yaml + +from bisheng.common.services.config_service import ConfigService + +FILE_CONFIG = """\ +# 灵思模块相关配置 +linsight: + # 历史记录中工具消息的最大token + tool_buffer: 100000 + # 单个任务最大执行步骤数 + max_steps: 2500 + # 主图轮次预算-一次任务最多允许多少次模型调用 + max_model_turns: 600 + # 子代理轮次预算 + max_model_turns_subagent: 120 + +# 新增的顶层段 +brand_new: + # 带注释的新配置 + enabled: true +""" + +DB_CONFIG = """\ +# 灵思模块相关配置 +linsight: + # 历史记录中工具消息的最大token + tool_buffer: 100000 + # 单个任务最大执行步骤数 + max_steps: 200 +""" + + +def _merge(file_cfg=FILE_CONFIG, db_cfg=DB_CONFIG): + merged, added = ConfigService.merge_missing_config(file_cfg, db_cfg) + return yaml.safe_load(merged), added, merged + + +def test_missing_nested_keys_are_added(): + """The production case: the section exists, the new keys inside it do not.""" + cfg, added, _ = _merge() + + assert cfg["linsight"]["max_model_turns"] == 600 + assert cfg["linsight"]["max_model_turns_subagent"] == 120 + assert "linsight.max_model_turns" in added + assert "linsight.max_model_turns_subagent" in added + + +def test_existing_values_are_never_overwritten(): + """max_steps is 200 in the DB and 2500 in the file — the DB wins.""" + cfg, added, _ = _merge() + + assert cfg["linsight"]["max_steps"] == 200 + assert not any(a.endswith("max_steps") for a in added) + + +def test_missing_top_level_section_is_added_whole(): + cfg, added, _ = _merge() + + assert cfg["brand_new"]["enabled"] is True + assert "brand_new" in added + + +def test_comments_travel_with_the_key(): + """Operators read these comments in the config page; a yaml round-trip would + have dropped every one of them.""" + _, _, merged = _merge() + + assert "# 主图轮次预算-一次任务最多允许多少次模型调用" in merged + assert "# 子代理轮次预算" in merged + assert "# 带注释的新配置" in merged + # The DB's own comments survive too. + assert "# 历史记录中工具消息的最大token" in merged + + +def test_db_only_keys_are_preserved(): + """A setting the operator added by hand must not be dropped.""" + db = DB_CONFIG + "\n# 运维手工加的\ncustom_section:\n keep_me: yes\n" + cfg, _, merged = _merge(db_cfg=db) + + assert cfg["custom_section"]["keep_me"] is True + assert "# 运维手工加的" in merged + + +def test_nothing_missing_is_a_byte_identical_no_op(): + """Repeated boots must not rewrite the row or evict its cache.""" + merged, added = ConfigService.merge_missing_config(FILE_CONFIG, FILE_CONFIG) + + assert added == [] + assert merged == FILE_CONFIG + + +def test_merge_is_idempotent(): + once, added_once = ConfigService.merge_missing_config(FILE_CONFIG, DB_CONFIG) + twice, added_twice = ConfigService.merge_missing_config(FILE_CONFIG, once) + + assert added_once + assert added_twice == [] + assert twice == once + + +def test_result_stays_valid_yaml_with_correct_indentation(): + _, _, merged = _merge() + cfg = yaml.safe_load(merged) + + # Inserted children landed INSIDE the section, not at the top level. + assert "max_model_turns" not in cfg + assert set(cfg["linsight"]) == { + "tool_buffer", + "max_steps", + "max_model_turns", + "max_model_turns_subagent", + } + + +def test_empty_or_malformed_inputs_are_left_alone(): + assert ConfigService.merge_missing_config("", DB_CONFIG) == (DB_CONFIG, []) + # A scalar document is not a config tree — refuse rather than mangle it. + merged, added = ConfigService.merge_missing_config("just a string", DB_CONFIG) + assert (merged, added) == (DB_CONFIG, []) + + +def test_scalar_vs_section_mismatch_is_skipped(): + """File says section, DB says scalar (or vice versa) — leave the DB alone + rather than guess which shape is right.""" + file_cfg = "linsight:\n max_model_turns: 600\n" + db_cfg = "linsight: disabled\n" + + merged, added = ConfigService.merge_missing_config(file_cfg, db_cfg) + + assert added == [] + assert yaml.safe_load(merged)["linsight"] == "disabled" diff --git a/src/backend/test/linsight/test_code_interpreter_escape_guard.py b/src/backend/test/linsight/test_code_interpreter_escape_guard.py new file mode 100644 index 0000000000..11d348044e --- /dev/null +++ b/src/backend/test/linsight/test_code_interpreter_escape_guard.py @@ -0,0 +1,150 @@ +"""The code interpreter must refuse to read the host filesystem. + +``LocalExecutor`` is a subprocess on the SHARED backend host, not a sandbox: a +script can read anything the service account can. A production daily-chat turn +walked ``/tmp``, ``/app``, ``/home``, ``/data`` and ``~``, landed in +``/root/.cache/bisheng/bisheng/`` — the GLOBAL download cache where every user's +uploads pile up under a flat ``_`` — and answered from a document +that belonged to a different conversation. + +Annotating that after the fact (the ``absolute_path_advisory`` model) would be +useless: by then the other user's data is already in the model's context. So the +guard rejects the run BEFORE anything executes. + +Two exemptions have to hold or legitimate code breaks: + * prose is not access — a script may print a host path it is talking about; + * linsight hands the model host paths OF ITS OWN workspace + (``path_namespace_rules`` literally shows ``/root/.cache/.../output/a.png``), + so a literal under ``local_sync_path`` stays legal. + +No subprocess, matplotlib or MinIO is involved. +""" + +from __future__ import annotations + +import os + +import pytest + +from bisheng_langchain.gpts.tools.code_interpreter.base_executor import WORKSPACE_ESCAPE_NOTICE +from bisheng_langchain.gpts.tools.code_interpreter.local_executor import LocalExecutor + +_MINIO = {"public_bucket": "bisheng", "tmp_bucket": "tmp-dir"} + + +def _executor(local_sync_path: str | None = None) -> LocalExecutor: + return LocalExecutor(minio=_MINIO, local_sync_path=local_sync_path) + + +# --------------------------------------------------------------------------- +# Rejected: real filesystem access outside the working dir +# --------------------------------------------------------------------------- +@pytest.mark.parametrize( + "code", + [ + # The exact shapes the production run used. + "import os\nfor root, dirs, files in os.walk('/root'):\n print(files)", + "import os\nfor p in ['/tmp', '/app', '/home', '/data']:\n os.listdir('/app')", + "open('/root/.cache/bisheng/bisheng/84da_P_RfB.pdf', 'rb')", + 'import fitz\ndoc = fitz.open("/root/.cache/bisheng/bisheng/abc_tender.pdf")', + "import glob\nglob.glob('/data/*.pdf')", + "from pathlib import Path\nPath('/etc/passwd').read_text()", + "import os\nos.path.exists('/var/log/app.log')", + "import shutil\nshutil.copy('/home/other/report.xlsx', 'output/x.xlsx')", + # Home expansion — how the model reached for "the file I was given". + "import os\nos.walk(os.path.expanduser('~'))", + 'import os\nprint(os.listdir(os.path.expanduser("~")))', + "from pathlib import Path\nprint(list(Path.home().iterdir()))", + # A scan rooted at / walks the whole container. + "import os\nfor root, dirs, files in os.walk('/'):\n pass", + "import glob\nglob.glob('/')", + ], +) +def test_guard_rejects_host_access(code): + assert _executor().workspace_escape_guard(code) == WORKSPACE_ESCAPE_NOTICE + + +def test_run_rejects_before_executing_anything(tmp_path): + """A rejected run must not touch the filesystem at all.""" + executor = _executor() + result = executor.run("import os\nprint(os.listdir('/root'))") + + assert result["exitcode"] == 1 + assert result["log"] == WORKSPACE_ESCAPE_NOTICE + assert result["file_list"] == [] + + +# --------------------------------------------------------------------------- +# Allowed: ordinary workspace code +# --------------------------------------------------------------------------- +@pytest.mark.parametrize( + "code", + [ + "with open('output/report.pdf', 'wb') as f: f.write(b'x')", + "import os\nos.makedirs('output', exist_ok=True)", + "import fitz\ndoc = fitz.open('uploads/tender.pdf')", + "import glob\nglob.glob('scratch/*.png')", + "from pathlib import Path\nPath('output/a.md').read_text()", + "import os\nfor root, dirs, files in os.walk('.'):\n print(files)", + # Prose ABOUT a host path is not access to it — the whole reason the + # patterns are verb-anchored rather than matching bare literals. + "print('nothing was found under /root/.cache, please re-upload')", + "msg = '/etc/hosts is not readable here'\nprint(msg)", + # A variable named like a verb must not drag an unrelated literal in. + "home = '/home/report.pdf' # noqa\nprint('done')", + ], +) +def test_guard_allows_workspace_code(code): + assert _executor().workspace_escape_guard(code) == "" + + +def test_guard_allows_absolute_path_into_own_workspace(): + """Linsight shows the model host paths of its own workspace; keep them legal.""" + file_dir = "/root/.cache/bisheng/linsight/8d2747aa" + executor = _executor(local_sync_path=file_dir) + + assert executor.workspace_escape_guard(f"open('{file_dir}/output/a.png', 'rb')") == "" + assert executor.workspace_escape_guard(f"open('{file_dir}')") == "" + # A sibling task's dir shares the prefix but is NOT this workspace. + assert ( + executor.workspace_escape_guard("open('/root/.cache/bisheng/linsight/ffffffff/output/a.png')") + == WORKSPACE_ESCAPE_NOTICE + ) + # The parent cache dir holds every user's uploads — still rejected. + assert ( + executor.workspace_escape_guard("import os\nos.listdir('/root/.cache/bisheng/bisheng')") + == WORKSPACE_ESCAPE_NOTICE + ) + + +def test_guard_ignores_empty_code(): + assert _executor().workspace_escape_guard("") == "" + + +# --------------------------------------------------------------------------- +# HOME redirect: `~` must resolve inside the workspace, not to the service home +# --------------------------------------------------------------------------- +def test_child_env_points_home_at_the_working_dir(tmp_path): + env = LocalExecutor._child_env(str(tmp_path)) + + assert env["HOME"] == str(tmp_path) + # Pinned BEFORE HOME moves, or matplotlib rebuilds its font cache every run. + assert env["MPLCONFIGDIR"] not in ("", str(tmp_path)) + assert os.path.isabs(env["MPLCONFIGDIR"]) + + +def test_child_env_keeps_an_explicit_mplconfigdir(tmp_path, monkeypatch): + monkeypatch.setenv("MPLCONFIGDIR", "/opt/mpl-cache") + + env = LocalExecutor._child_env(str(tmp_path)) + + assert env["MPLCONFIGDIR"] == "/opt/mpl-cache" + assert env["HOME"] == str(tmp_path) + + +def test_child_env_without_work_dir_is_untouched(monkeypatch): + monkeypatch.setenv("HOME", "/root") + + env = LocalExecutor._child_env(None) + + assert env["HOME"] == "/root" diff --git a/src/backend/test/linsight/test_folder_upload.py b/src/backend/test/linsight/test_folder_upload.py new file mode 100644 index 0000000000..405700fe47 --- /dev/null +++ b/src/backend/test/linsight/test_folder_upload.py @@ -0,0 +1,355 @@ +"""Task-mode FOLDER upload: keep the user's directory tree inside the workspace. + +Before this, every attachment landed flat in ``uploads/`` because +``_safe_basename`` collapsed path separators to ``_``. These tests pin the three +things that had to change for a folder to survive the trip: + + - ``_safe_relpath`` — a path-PRESERVING sanitizer (traversal still neutralized) + - ``_dedupe_workspace_name`` — uniqueness scoped to the full relative path, so + two ``summary.md`` in different directories stop overwriting each other + - ``_write_attachment_to_workspace`` — nested object keys for both tracks + (markdown view + raw original) + +plus the submit-time batch gate (`_validate_folder_upload`) and the pointer-block +rendering that keeps a large folder from flooding the first user message. + +External services (Redis, MinIO) are mocked; no live middleware required. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from bisheng.common.errcode.linsight import ( + LinsightFolderDepthExceededError, + LinsightFolderFileCountExceededError, + LinsightFolderTotalSizeExceededError, +) +from bisheng.linsight.domain.models.linsight_session_version import LinsightSessionVersion +from bisheng.linsight.domain.schemas.linsight_schema import SubmitFileSchema +from bisheng.linsight.domain.services.workbench_impl import LinsightWorkbenchImpl as Impl + + +class FakeMinio: + def __init__(self) -> None: + self.bucket = "bisheng" + self.tmp_bucket = "tmp-dir" + self.store: dict[tuple[str, str], bytes] = {} + + async def object_exists(self, bucket_name=None, object_name=None): + return (bucket_name or self.bucket, object_name) in self.store + + async def copy_object(self, source_bucket=None, source_object=None, dest_bucket=None, dest_object=None): + src = self.store.get((source_bucket or self.tmp_bucket, source_object), b"parsed-md") + self.store[(dest_bucket or self.bucket, dest_object)] = src + + async def get_object(self, bucket_name=None, object_name=None): + return self.store.get((bucket_name or self.bucket, object_name)) + + async def put_object(self, *, bucket_name=None, object_name, file, **kwargs): + self.store[(bucket_name or self.bucket, object_name)] = file if isinstance(file, bytes) else bytes(file) + + +def _submit(file_id="f1", name="Q1.xlsx", rel=None, size=0): + return SubmitFileSchema( + file_id=file_id, + file_name=name, + parsing_status="completed", + relative_path=rel, + size=size, + ) + + +def _temp_info(file_id="f1", name="Q1.xlsx"): + return { + "file_id": file_id, + "original_filename": name, + "parsing_status": "completed", + "markdown_filename": f"{file_id}.md", + "markdown_file_path": f"{file_id}.md", + } + + +def _session(files): + return LinsightSessionVersion(session_id="chat1", user_id=1, question="q", files=files) + + +# --------------------------------------------------------------------------- +# _safe_relpath — preserve the tree, neutralize traversal +# --------------------------------------------------------------------------- +def test_safe_relpath_keeps_nested_directories(): + assert Impl._safe_relpath("年报/2024/Q1.xlsx") == "年报/2024" + assert Impl._safe_relpath("Docs/a.pdf") == "Docs" + + +def test_safe_relpath_flat_inputs_yield_no_directory(): + """A plain single-file upload must keep the historical flat layout.""" + assert Impl._safe_relpath(None) == "" + assert Impl._safe_relpath("") == "" + assert Impl._safe_relpath(" ") == "" + assert Impl._safe_relpath("report.pdf") == "" + + +@pytest.mark.parametrize( + "crafted", + [ + "../../etc/passwd", + "/etc/passwd", + "a/../../../b/c.pdf", + "./x/./y/z.pdf", + "..\\..\\windows\\system32\\cfg.txt", + ], +) +def test_safe_relpath_cannot_escape_uploads(crafted): + """Every `.`/`..`/empty segment is dropped, so the result is always relative + and always lands under ``uploads/``.""" + result = Impl._safe_relpath(crafted) + assert not result.startswith("/") + assert ".." not in result.split("/") + assert "." not in result.split("/") + + +def test_safe_relpath_clamps_depth(): + deep = "/".join(f"d{i}" for i in range(20)) + "/f.txt" + assert len(Impl._safe_relpath(deep).split("/")) == Impl._FOLDER_MAX_DEPTH + + +def test_safe_relpath_sanitizes_each_segment(): + """Control characters die per segment, non-ASCII names survive.""" + assert Impl._safe_relpath("年\x01报/2024/a.pdf") == "年报/2024" + + +# --------------------------------------------------------------------------- +# _dedupe_workspace_name — uniqueness namespace is the FULL relative path +# --------------------------------------------------------------------------- +def test_same_name_in_different_directories_does_not_collide(): + used: set[str] = set() + a = Impl._dedupe_workspace_name("报告/summary.md", used) + b = Impl._dedupe_workspace_name("附件/summary.md", used) + assert a == "报告/summary.md" + assert b == "附件/summary.md" + + +def test_real_collision_suffixes_the_filename_not_the_directory(): + used: set[str] = set() + Impl._dedupe_workspace_name("v1.2/report.pdf", used) + second = Impl._dedupe_workspace_name("v1.2/report.pdf", used) + # The directory keeps its dot; only the file name gets the -2 marker. + assert second == "v1.2/report-2.pdf" + + +def test_flat_dedupe_behaviour_unchanged(): + used: set[str] = set() + assert Impl._dedupe_workspace_name("a.md", used) == "a.md" + assert Impl._dedupe_workspace_name("a.md", used) == "a-2.md" + assert Impl._dedupe_workspace_name("a.md", used) == "a-3.md" + + +# --------------------------------------------------------------------------- +# _validate_folder_upload — all-or-nothing batch gate +# --------------------------------------------------------------------------- +def test_plain_multi_file_selection_is_not_gated(): + """No relative_path anywhere => not a folder upload => historical behaviour.""" + files = [_submit(file_id=f"f{i}", name=f"{i}.pdf") for i in range(Impl._FOLDER_MAX_FILES + 5)] + Impl._validate_folder_upload(files) # must not raise + + +def test_folder_file_count_is_capped(): + files = [_submit(file_id=f"f{i}", name=f"{i}.pdf", rel=f"docs/{i}.pdf") for i in range(Impl._FOLDER_MAX_FILES + 1)] + with pytest.raises(LinsightFolderFileCountExceededError): + Impl._validate_folder_upload(files) + + +def test_folder_total_size_is_capped(): + half = Impl._FOLDER_MAX_TOTAL_BYTES // 2 + 1 + files = [ + _submit(file_id="f1", name="a.pdf", rel="docs/a.pdf", size=half), + _submit(file_id="f2", name="b.pdf", rel="docs/b.pdf", size=half), + ] + with pytest.raises(LinsightFolderTotalSizeExceededError): + Impl._validate_folder_upload(files) + + +def test_folder_depth_is_capped(): + deep = "/".join(f"d{i}" for i in range(Impl._FOLDER_MAX_DEPTH + 1)) + "/f.pdf" + with pytest.raises(LinsightFolderDepthExceededError): + Impl._validate_folder_upload([_submit(rel=deep)]) + + +def test_folder_at_exactly_the_limits_is_accepted(): + deep = "/".join(f"d{i}" for i in range(Impl._FOLDER_MAX_DEPTH)) + "/f.pdf" + files = [_submit(file_id=f"f{i}", name=f"{i}.pdf", rel=deep) for i in range(Impl._FOLDER_MAX_FILES)] + Impl._validate_folder_upload(files) # must not raise + + +# --------------------------------------------------------------------------- +# End-to-end ingestion: nested object keys for both tracks +# --------------------------------------------------------------------------- +async def _ingest(submits, temp_infos): + fake_minio = FakeMinio() + fake_redis = AsyncMock() + for info in temp_infos: + fake_minio.store[(fake_minio.tmp_bucket, info["markdown_file_path"])] = b"# T\nbody\n" + fake_redis.amget.return_value = temp_infos + + with ( + patch.object(Impl, "_get_redis", return_value=fake_redis), + patch( + "bisheng.linsight.domain.services.workbench_impl.get_minio_storage", + new=AsyncMock(return_value=fake_minio), + ), + ): + result = await Impl._process_submitted_files(submits, "svid1") + return result, fake_minio + + +async def test_folder_upload_rebuilds_the_tree_in_the_workspace(): + submits = [_submit(file_id="f1", name="Q1.xlsx", rel="年报/2024/Q1.xlsx")] + result, minio = await _ingest(submits, [_temp_info("f1", "Q1.xlsx")]) + + assert result[0]["workspace_path"] == "/uploads/年报/2024/Q1.md" + assert ("bisheng", "workspace/svid1/uploads/年报/2024/Q1.md") in minio.store + + +async def test_flat_upload_layout_is_untouched(): + submits = [_submit(file_id="f1", name="My Report.pdf")] + result, minio = await _ingest(submits, [_temp_info("f1", "My Report.pdf")]) + + assert result[0]["workspace_path"] == "/uploads/My Report.md" + assert ("bisheng", "workspace/svid1/uploads/My Report.md") in minio.store + + +async def test_same_filename_in_two_directories_both_survive(): + """The pre-change flat namespace silently overwrote the first file.""" + submits = [ + _submit(file_id="f1", name="summary.pdf", rel="报告/summary.pdf"), + _submit(file_id="f2", name="summary.pdf", rel="附件/summary.pdf"), + ] + result, minio = await _ingest(submits, [_temp_info("f1", "summary.pdf"), _temp_info("f2", "summary.pdf")]) + + assert result[0]["workspace_path"] == "/uploads/报告/summary.md" + assert result[1]["workspace_path"] == "/uploads/附件/summary.md" + assert ("bisheng", "workspace/svid1/uploads/报告/summary.md") in minio.store + assert ("bisheng", "workspace/svid1/uploads/附件/summary.md") in minio.store + + +async def test_raw_original_lands_beside_its_markdown_view(): + """The code interpreter opens ``raw`` by the path the pointer block prints, + so the original must sit in the SAME nested directory as the .md.""" + info = _temp_info("f1", "Q1.xlsx") + info["original_file_path"] = "tmp/f1_original.xlsx" + + fake_minio = FakeMinio() + fake_redis = AsyncMock() + fake_minio.store[(fake_minio.tmp_bucket, info["markdown_file_path"])] = b"# T\n" + fake_minio.store[(fake_minio.tmp_bucket, "tmp/f1_original.xlsx")] = b"PK\x03\x04xlsx" + fake_redis.amget.return_value = [info] + + with ( + patch.object(Impl, "_get_redis", return_value=fake_redis), + patch( + "bisheng.linsight.domain.services.workbench_impl.get_minio_storage", + new=AsyncMock(return_value=fake_minio), + ), + ): + result = await Impl._process_submitted_files( + [_submit(file_id="f1", name="Q1.xlsx", rel="年报/2024/Q1.xlsx")], "svid1" + ) + + assert result[0]["raw_workspace_path"] == "/uploads/年报/2024/Q1.xlsx" + assert ("bisheng", "workspace/svid1/uploads/年报/2024/Q1.xlsx") in fake_minio.store + + +async def test_crafted_relative_path_stays_inside_uploads(): + submits = [_submit(file_id="f1", name="evil.pdf", rel="../../../../etc/evil.pdf")] + _, minio = await _ingest(submits, [_temp_info("f1", "evil.pdf")]) + + written = [key for (_, key) in minio.store if key.startswith("workspace/")] + assert written + for key in written: + assert key.startswith("workspace/svid1/uploads/") + assert ".." not in key + + +# --------------------------------------------------------------------------- +# prepare_file_list rendering +# --------------------------------------------------------------------------- +async def test_pointer_block_groups_by_directory(): + files = [ + { + "file_id": "f1", + "original_filename": "Q1.xlsx", + "relative_path": "年报/2024/Q1.xlsx", + "workspace_path": "/uploads/年报/2024/Q1.md", + "line_count": 5, + }, + { + "file_id": "f2", + "original_filename": "note.md", + "relative_path": "附件/note.md", + "workspace_path": "/uploads/附件/note.md", + "line_count": 3, + }, + ] + block = (await Impl.prepare_file_list(_session(files)))[0] + + assert "说明(文件夹)" in block + assert "[目录] /uploads/年报/2024/" in block + assert "[目录] /uploads/附件/" in block + assert "path: /uploads/年报/2024/Q1.md" in block + + +async def test_flat_pointer_block_has_no_folder_scaffolding(): + """A plain submission must render exactly as it always did.""" + files = [ + { + "file_id": "f1", + "original_filename": "My Report.pdf", + "workspace_path": "/uploads/My Report.md", + "line_count": 42, + "image_count": 3, + } + ] + block = (await Impl.prepare_file_list(_session(files)))[0] + + assert "说明(文件夹)" not in block + assert "[目录]" not in block + assert "path: /uploads/My Report.md" in block + + +async def test_legacy_index_md_path_is_not_mistaken_for_a_directory(): + """``/uploads//index.md`` is a legacy fallback shape, not a folder — + grouping reads ``relative_path``, never the workspace path.""" + files = [ + { + "file_id": "f1", + "original_filename": "My Report.pdf", + "workspace_path": "/uploads/my-report.pdf/index.md", + "line_count": 42, + } + ] + block = (await Impl.prepare_file_list(_session(files)))[0] + assert "[目录]" not in block + + +async def test_large_folder_degrades_to_a_directory_summary(): + files = [ + { + "file_id": f"f{i}", + "original_filename": f"doc{i}.pdf", + "relative_path": f"资料/{i}.pdf", + "workspace_path": f"/uploads/资料/doc{i}.md", + "line_count": 1, + } + for i in range(Impl._FILE_LIST_MAX_ITEMS + 1) + ] + block = (await Impl.prepare_file_list(_session(files)))[0] + + assert "dir: /uploads/资料/" in block + assert f"files: {Impl._FILE_LIST_MAX_ITEMS + 1}" in block + assert "pdf×" in block + # The per-file pointers are exactly what the summary replaces. + assert "path: /uploads/资料/doc0.md" not in block + assert "glob" in block diff --git a/src/backend/test/linsight/test_start_execute_persists_task_turn.py b/src/backend/test/linsight/test_start_execute_persists_task_turn.py index 09809292c9..581fcccf4c 100644 --- a/src/backend/test/linsight/test_start_execute_persists_task_turn.py +++ b/src/backend/test/linsight/test_start_execute_persists_task_turn.py @@ -52,7 +52,11 @@ def patched_endpoint(monkeypatch): AsyncMock(return_value=_session()), ) monkeypatch.setattr(endpoint.MessageSessionDao, "touch_session", AsyncMock()) + # Enqueueing now lives in linsight_execute_utils (shared with the unified + # submit path), so the Redis stub belongs on THAT module — patching only the + # endpoint's name let the real client through and the call reached the DB. monkeypatch.setattr(endpoint, "get_redis_client", AsyncMock(return_value=SimpleNamespace())) + monkeypatch.setattr(endpoint.linsight_execute_utils, "get_redis_client", AsyncMock(return_value=SimpleNamespace())) # LinsightQueue and encode_queue_item are imported function-locally from # bisheng.linsight.worker; inject a stub module so the heavy worker import @@ -111,14 +115,23 @@ async def _boom(_session_model): assert resp.data is True -async def test_start_execute_rejects_in_progress(monkeypatch, patched_endpoint): - """An already-running session is rejected and never re-persisted/enqueued.""" +async def test_start_execute_on_in_progress_is_a_no_op(monkeypatch, patched_endpoint): + """An already-running session is left alone — never re-persisted or re-enqueued. + + It now answers 200 rather than an error: submit enqueues server-side, so the + client's start-execute routinely arrives after the worker already picked the + session up, and reporting that as a failure made the UI mark a healthy task + as stopped. Idempotency itself is covered in + test_task_submit_server_side_enqueue.py. + """ monkeypatch.setattr( endpoint.LinsightSessionVersionDao, "get_by_id", AsyncMock(return_value=_session(status=SessionVersionStatusEnum.IN_PROGRESS)), ) - await endpoint.start_execute(linsight_session_version_id="SV-1", login_user=_login_user()) + resp = await endpoint.start_execute(linsight_session_version_id="SV-1", login_user=_login_user()) + assert resp.data is True assert "session" not in patched_endpoint # persist never reached + patched_endpoint["queue"].put.assert_not_awaited() diff --git a/src/backend/test/linsight/test_task_submit_server_side_enqueue.py b/src/backend/test/linsight/test_task_submit_server_side_enqueue.py new file mode 100644 index 0000000000..45d61db4c6 --- /dev/null +++ b/src/backend/test/linsight/test_task_submit_server_side_enqueue.py @@ -0,0 +1,135 @@ +"""A task-mode submit must enqueue itself, without waiting for the browser. + +Enqueueing used to be the client's job: submit created the session and streamed +a ``linsight_task_handoff`` event, and only then did the browser POST +``/workbench/start-execute``. Everything between those two steps was a window in +which the task could be lost — and it was not a narrow one, because +``submit_user_question`` parses every attachment inline. A production task with +12 attachments spent minutes in that call; the user stopped waiting, so the +second request never came and the session sat at NOT_STARTED forever. The +conversation lost its task row too, which is why even the task-mode badge +vanished on reload. + +So: submit enqueues server-side, and start-execute degrades to a late retry. +That makes double-enqueue the normal case (server + client), which is safe +because the executor rejects re-entry on an already-running session — but it +also means start-execute must stop reporting "already running" as an error, or +the frontend's `.catch` marks a perfectly healthy task as failed. + +Pure unit tests: no Redis, no DB, no worker. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from bisheng.linsight.domain import utils as linsight_execute_utils +from bisheng.linsight.domain.models.linsight_session_version import SessionVersionStatusEnum + + +def _session(status=SessionVersionStatusEnum.NOT_STARTED, user_id=1): + return SimpleNamespace( + id="sv-1", + session_id="chat-1", + tenant_id=1, + user_id=user_id, + status=status, + ) + + +# --------------------------------------------------------------------------- +# enqueue_session_for_execution: the shared entry point +# --------------------------------------------------------------------------- +async def test_enqueue_puts_the_session_on_the_worker_queue(monkeypatch: pytest.MonkeyPatch): + put = AsyncMock() + + class _Queue: + def __init__(self, *args, **kwargs): + self.put = put + + import bisheng.linsight.worker as worker_mod + + monkeypatch.setattr(worker_mod, "LinsightQueue", _Queue) + monkeypatch.setattr(worker_mod, "encode_queue_item", lambda svid, tenant_id: f"{svid}:{tenant_id}") + monkeypatch.setattr(linsight_execute_utils, "get_redis_client", AsyncMock(return_value=object())) + + await linsight_execute_utils.enqueue_session_for_execution(_session()) + + put.assert_awaited_once() + assert put.await_args.kwargs["data"] == "sv-1:1" + + +# --------------------------------------------------------------------------- +# start-execute: idempotent for a session the server already enqueued +# --------------------------------------------------------------------------- +@pytest.fixture +def endpoint_env(monkeypatch: pytest.MonkeyPatch): + from bisheng.linsight.api.endpoints import linsight as ep + + state: dict = {"enqueued": 0, "session": _session()} + + async def _get_by_id(linsight_session_version_id): + return state["session"] + + async def _enqueue(session_model): + state["enqueued"] += 1 + + monkeypatch.setattr(ep.LinsightSessionVersionDao, "get_by_id", AsyncMock(side_effect=_get_by_id)) + monkeypatch.setattr(ep.MessageSessionDao, "touch_session", AsyncMock()) + monkeypatch.setattr(ep.linsight_execute_utils, "enqueue_session_for_execution", _enqueue) + monkeypatch.setattr(ep.linsight_execute_utils, "persist_task_turn_message", AsyncMock()) + return ep, state + + +async def test_start_execute_enqueues_a_pending_session(endpoint_env): + ep, state = endpoint_env + login_user = SimpleNamespace(user_id=1) + + resp = await ep.start_execute(linsight_session_version_id="sv-1", login_user=login_user) + + assert resp.status_code == 200 + assert state["enqueued"] == 1 + + +async def test_start_execute_on_a_running_session_is_a_successful_no_op(endpoint_env): + """The regression: server-side enqueue means the worker often picks the + session up BEFORE the client's start-execute lands. Answering with an error + made the UI show a running task as failed.""" + ep, state = endpoint_env + state["session"] = _session(status=SessionVersionStatusEnum.IN_PROGRESS) + login_user = SimpleNamespace(user_id=1) + + resp = await ep.start_execute(linsight_session_version_id="sv-1", login_user=login_user) + + assert resp.status_code == 200 + # Must NOT enqueue a second time — the run is already under way. + assert state["enqueued"] == 0 + + +@pytest.mark.parametrize( + "status", + [SessionVersionStatusEnum.COMPLETED, SessionVersionStatusEnum.TERMINATED], +) +async def test_start_execute_still_refuses_a_finished_session(endpoint_env, status): + ep, state = endpoint_env + state["session"] = _session(status=status) + login_user = SimpleNamespace(user_id=1) + + resp = await ep.start_execute(linsight_session_version_id="sv-1", login_user=login_user) + + assert resp.status_code != 200 + assert state["enqueued"] == 0 + + +async def test_start_execute_rejects_another_users_session(endpoint_env): + ep, state = endpoint_env + state["session"] = _session(user_id=999) + login_user = SimpleNamespace(user_id=1) + + resp = await ep.start_execute(linsight_session_version_id="sv-1", login_user=login_user) + + assert resp.status_code != 200 + assert state["enqueued"] == 0 diff --git a/src/backend/test/linsight/test_workbench_attachments.py b/src/backend/test/linsight/test_workbench_attachments.py index 70849d4d83..3db29291d9 100644 --- a/src/backend/test/linsight/test_workbench_attachments.py +++ b/src/backend/test/linsight/test_workbench_attachments.py @@ -179,7 +179,9 @@ async def test_prepare_file_list_media_and_docx_headers_both_present(): ) block = "\n".join(await LinsightWorkbenchImpl.prepare_file_list(sv, has_code_interpreter=False)) assert "说明(音视频)" in block - assert "raw 是原始二进制文件" in block + # The dual-track clause plus its no-code-interpreter degradation. + assert "raw 指向同名原件" in block + assert "无法读取二进制原件" in block async def test_prepare_file_list_empty(): @@ -453,3 +455,182 @@ async def test_expired_temp_no_formal_marks_invalid(): assert entry["file_id"] == "f1" # not silently dropped: carries a status the frontend can branch on assert entry.get("parsing_status") in ("expired", "invalid") + + +async def test_daily_passthrough_file_never_touches_the_parser(tmp_path): + """A .py has no loader, so the old path called the ETL purely to catch its + exception — one wasted round-trip plus a logger.exception that reads like a + real failure. The parser must not be constructed at all.""" + constructed = [] + + class _ExplodingPipeline: + def __init__(self, *args, **kwargs): + constructed.append(kwargs.get("file_name")) + raise AssertionError("passthrough types must not reach the parser") + + local = tmp_path / "analyze.py" + local.write_text("import os\nprint(os.getcwd())\n", encoding="utf-8") + fake_minio = FakeMinio() + submit = SubmitFileSchema( + file_id="p1", + file_name="analyze.py", + parsing_status="completed", + file_url="/tmp-dir/analyze.py?X-Amz-Algorithm=AWS4", + ) + + with ( + patch( + "bisheng.linsight.domain.services.workbench_impl.get_minio_storage", + new=AsyncMock(return_value=fake_minio), + ), + patch.object(LinsightWorkbenchImpl, "_get_redis", return_value=AsyncMock()), + patch( + "bisheng.core.cache.utils.async_file_download", + new=AsyncMock(return_value=(str(local), "analyze.py")), + ), + patch("bisheng.knowledge.rag.temp_file_pipeline.TempFilePipeline", _ExplodingPipeline), + ): + result = await LinsightWorkbenchImpl._process_submitted_files([submit], "svid9", user_id=7) + + assert constructed == [] + entry = result[0] + assert entry["parsing_status"] == "completed" + assert entry["valid"] is True + assert entry["ingest_mode"] == "passthrough" + assert entry["workspace_path"] == "/uploads/analyze.py" + ws_keys = [k for (_b, k) in fake_minio.store if k.startswith("workspace/svid9/uploads/")] + assert ws_keys == ["workspace/svid9/uploads/analyze.py"] + + +async def test_daily_unsupported_type_short_circuits_the_parser(tmp_path): + """An .exe would be rejected by the pipeline on the same extension check, so + calling it buys nothing but latency and a misleading traceback.""" + constructed = [] + + class _ExplodingPipeline: + def __init__(self, *args, **kwargs): + constructed.append(kwargs.get("file_name")) + raise AssertionError("unsupported types must not reach the parser") + + local = tmp_path / "setup.exe" + local.write_bytes(b"MZ\x90\x00") + fake_minio = FakeMinio() + submit = SubmitFileSchema( + file_id="p2", + file_name="setup.exe", + parsing_status="completed", + file_url="/tmp-dir/setup.exe?X-Amz-Algorithm=AWS4", + ) + + with ( + patch( + "bisheng.linsight.domain.services.workbench_impl.get_minio_storage", + new=AsyncMock(return_value=fake_minio), + ), + patch.object(LinsightWorkbenchImpl, "_get_redis", return_value=AsyncMock()), + patch( + "bisheng.core.cache.utils.async_file_download", + new=AsyncMock(return_value=(str(local), "setup.exe")), + ), + patch("bisheng.knowledge.rag.temp_file_pipeline.TempFilePipeline", _ExplodingPipeline), + ): + result = await LinsightWorkbenchImpl._process_submitted_files([submit], "svid10", user_id=7) + + assert constructed == [] + entry = result[0] + assert entry["parsing_status"] == "unsupported" + assert entry["valid"] is False + assert not [k for (_b, k) in fake_minio.store if k.startswith("workspace/")] + + +async def test_daily_csv_passes_through_as_one_file(tmp_path): + """csv has a loader, but that loader is a RAG chunker: it slices every N rows + and repeats the header per chunk. The csv is already plain text the model can + read_file directly, so the "reading view" would be a reshuffle of something + already readable, stored twice.""" + + class _ExplodingPipeline: + def __init__(self, *args, **kwargs): + raise AssertionError("csv must not be chunked by the RAG loader") + + local = tmp_path / "data.csv" + local.write_text("a,b\n1,2\n", encoding="utf-8") + fake_minio = FakeMinio() + submit = SubmitFileSchema( + file_id="p3", + file_name="data.csv", + parsing_status="completed", + file_url="/tmp-dir/data.csv?X-Amz-Algorithm=AWS4", + ) + + with ( + patch( + "bisheng.linsight.domain.services.workbench_impl.get_minio_storage", + new=AsyncMock(return_value=fake_minio), + ), + patch.object(LinsightWorkbenchImpl, "_get_redis", return_value=AsyncMock()), + patch( + "bisheng.core.cache.utils.async_file_download", + new=AsyncMock(return_value=(str(local), "data.csv")), + ), + patch("bisheng.knowledge.rag.temp_file_pipeline.TempFilePipeline", _ExplodingPipeline), + ): + result = await LinsightWorkbenchImpl._process_submitted_files([submit], "svid11", user_id=7) + + entry = result[0] + assert entry["ingest_mode"] == "passthrough" + assert entry["valid"] is True + assert entry["workspace_path"] == "/uploads/data.csv" + # One file, not a .md view plus a copy of the original. + ws_keys = sorted(k for (_b, k) in fake_minio.store if k.startswith("workspace/svid11/uploads/")) + assert ws_keys == ["workspace/svid11/uploads/data.csv"] + + +async def test_daily_html_still_parses(tmp_path): + """REGRESSION: markup is the one carve-out — stripping tags is a real + conversion, and raw HTML is genuinely worse to read than the text inside.""" + + class _Doc: + def __init__(self, content): + self.page_content = content + + class _Result: + documents = [_Doc("# Title\nbody\n")] + + calls = [] + + class _FakePipeline: + def __init__(self, *args, **kwargs): + calls.append(kwargs.get("file_name")) + + async def arun(self): + return _Result() + + local = tmp_path / "page.html" + local.write_text("body", encoding="utf-8") + fake_minio = FakeMinio() + submit = SubmitFileSchema( + file_id="p4", + file_name="page.html", + parsing_status="completed", + file_url="/tmp-dir/page.html?X-Amz-Algorithm=AWS4", + ) + + with ( + patch( + "bisheng.linsight.domain.services.workbench_impl.get_minio_storage", + new=AsyncMock(return_value=fake_minio), + ), + patch.object(LinsightWorkbenchImpl, "_get_redis", return_value=AsyncMock()), + patch( + "bisheng.core.cache.utils.async_file_download", + new=AsyncMock(return_value=(str(local), "page.html")), + ), + patch("bisheng.knowledge.rag.temp_file_pipeline.TempFilePipeline", _FakePipeline), + ): + result = await LinsightWorkbenchImpl._process_submitted_files([submit], "svid12", user_id=7) + + assert calls == ["page.html"] + entry = result[0] + assert entry.get("ingest_mode") is None + assert entry["workspace_path"] == "/uploads/page.md" diff --git a/src/backend/test/linsight/test_workspace_dual_track.py b/src/backend/test/linsight/test_workspace_dual_track.py index cfced35240..8c3514dba1 100644 --- a/src/backend/test/linsight/test_workspace_dual_track.py +++ b/src/backend/test/linsight/test_workspace_dual_track.py @@ -258,7 +258,7 @@ async def test_unsupported_original_is_not_written_to_workspace(): """An mp3 no parser, no read_file and no code interpreter can open is pure cost: storage, a confusing ls entry, and a wasted tool call.""" minio = FakeMinio({}) - submit = SimpleNamespace(file_id="f7", file_name="访谈录音.mp3") + submit = SimpleNamespace(file_id="f7", file_name="访谈录音.mp3", relative_path=None) local = __import__("tempfile").NamedTemporaryFile(suffix=".mp3", delete=False) local.write(b"ID3\x03\x00\x00\x00") local.close() @@ -277,7 +277,7 @@ async def test_unsupported_original_is_not_written_to_workspace(): async def test_usable_unparsed_original_still_lands(): minio = FakeMinio({}) - submit = SimpleNamespace(file_id="f8", file_name="扫描件.pdf") + submit = SimpleNamespace(file_id="f8", file_name="扫描件.pdf", relative_path=None) local = __import__("tempfile").NamedTemporaryFile(suffix=".pdf", delete=False) local.write(b"%PDF-1.4 broken") local.close() @@ -309,3 +309,199 @@ def test_content_type_is_platform_independent(): assert Impl._content_type_for("合同.pdf") == "application/pdf" assert Impl._content_type_for("data.csv") == "text/csv" assert Impl._content_type_for("unknown.bin") == "application/octet-stream" + + +# -------------------------------------------------------------------------- +# passthrough ingest: data/code files whose original IS the workspace file +# -------------------------------------------------------------------------- + + +def test_ingest_route_truth_table(): + """The route decides whether a file is parsed, passed through, or refused. + + Pinned as a table because the sets genuinely overlap and the tie-break lives + in ``_PARSE_WINS_EXTS``; an implicit ordering here would invert silently the + next time someone adds a key to ``FileExtensionMap``. + """ + # No loader exists, and none is wanted — the file is already final. + assert Impl._ingest_route("analyze.py") == "passthrough" + assert Impl._ingest_route("data.json") == "passthrough" + assert Impl._ingest_route("rows.jsonl") == "passthrough" + assert Impl._ingest_route("conf.yaml") == "passthrough" + assert Impl._ingest_route("query.sql") == "passthrough" + assert Impl._ingest_route("table.tsv") == "passthrough" + assert Impl._ingest_route("SETUP.SH") == "passthrough" # case-insensitive + + # Already-final text stays untouched even though a loader exists for it: the + # csv loader is a RAG chunker, and the txt/md round-trip rejoins split chunks + # with a blank line. Parsing these can only subtract. + assert Impl._ingest_route("data.csv") == "passthrough" + assert Impl._ingest_route("notes.txt") == "passthrough" + assert Impl._ingest_route("readme.md") == "passthrough" + assert Impl._ingest_route("readme.markdown") == "passthrough" + + # Markup is the one carve-out: stripping tags is a real conversion. + assert Impl._ingest_route("page.html") == "parse" + assert Impl._ingest_route("page.htm") == "parse" + + # Parser-only types are untouched by any of this. + assert Impl._ingest_route("book.xlsx") == "parse" + assert Impl._ingest_route("scan.pdf") == "parse" + assert Impl._ingest_route("photo.png") == "parse" + + # REGRESSION GUARD: media must never become passthrough. Their "parse" is the + # ASR transcription that makes them usable at all — passing an mp3 through + # would hand the model an unreadable binary instead of the transcript. + assert Impl._ingest_route("访谈录音.mp3") == "parse" + assert Impl._ingest_route("clip.mp4") == "parse" + + # No loader, no consumer: refused up front rather than after a failed ETL. + assert Impl._ingest_route("setup.exe") == "unsupported" + assert Impl._ingest_route("bundle.zip") == "unsupported" + assert Impl._ingest_route("") == "unsupported" + + +def test_parse_wins_exts_are_actually_parseable(): + """Every carve-out must have a loader, or it silently routes to a failure.""" + from bisheng.knowledge.rag.base_file_pipeline import FileExtensionMap + + assert Impl._PARSE_WINS_EXTS <= set(FileExtensionMap) + # And the carve-out only makes sense for types we would otherwise pass through. + assert Impl._PARSE_WINS_EXTS <= Impl._PASSTHROUGH_TEXT_EXTS + + +async def test_passthrough_entry_is_reported_as_success(): + """The historical failure marking made the chip cry wolf about a usable file. + + ``parsing_status`` also may not carry a new value: the frontend reads anything + other than completed/failed as "still parsing" and disables send, and + ``_process_submitted_files`` rejects the submission outright. + """ + minio = FakeMinio({}) + submit = SimpleNamespace(file_id="f9", file_name="analyze.py", relative_path=None) + + entry = await Impl._finalize_passthrough(submit, "analyze.py", "chat1", minio, b"import os\nprint(1)\n", set()) + + assert entry["parsing_status"] == "completed" + assert entry["valid"] is True + assert entry["ingest_mode"] == "passthrough" + assert entry["line_count"] == 3 + # The reading track and the raw track are the same file — that IS passthrough. + assert entry["workspace_path"] == "/uploads/analyze.py" + assert entry["raw_workspace_path"] == entry["workspace_path"] + assert entry["raw_filename"] == entry["markdown_filename"] == "analyze.py" + # Formal key keeps the real extension: the drawer builds its preview URL from + # it, and a .py served as .md would reach the markdown renderer. + assert entry["markdown_file_path"] == "linsight/chat1/f9.py" + assert entry["original_file_path"] == entry["markdown_file_path"] + # Exactly one workspace object — no phantom second copy. + assert [k for k in minio.puts if k.startswith("workspace/")] == ["workspace/chat1/uploads/analyze.py"] + + +async def test_text_parse_failure_degrades_to_passthrough(): + """A parse failure on a TEXT file costs nothing: the file reads perfectly well + as itself, so reporting it as failed would make the chip cry wolf.""" + minio = FakeMinio({}) + submit = SimpleNamespace(file_id="f10", file_name="page.html", relative_path=None) + local = __import__("tempfile").NamedTemporaryFile(suffix=".html", delete=False) + local.write(b"hi") + local.close() + + entry = await Impl._keep_original_in_workspace( + submit, "page.html", "chat1", minio, local.name, RuntimeError("loader exploded"), set() + ) + + assert entry["parsing_status"] == "completed" + assert entry["valid"] is True + assert entry["ingest_mode"] == "passthrough" + assert entry["workspace_path"] == "/uploads/page.html" + # The cause is kept for diagnosis even though the outcome is a success. + assert "loader exploded" in entry["error_message"] + + +async def test_binary_parse_failure_still_reports_failure(): + """A broken pdf really is broken: no text view, only the code interpreter.""" + minio = FakeMinio({}) + submit = SimpleNamespace(file_id="f11", file_name="扫描件.pdf", relative_path=None) + local = __import__("tempfile").NamedTemporaryFile(suffix=".pdf", delete=False) + local.write(b"%PDF-1.4 broken") + local.close() + + entry = await Impl._keep_original_in_workspace( + submit, "扫描件.pdf", "chat1", minio, local.name, RuntimeError("etl 403"), set() + ) + + assert entry["parsing_status"] == "failed" + assert entry["valid"] is False + assert entry.get("ingest_mode") is None + + +async def test_pointer_block_tells_the_truth_about_passthrough(): + files = [ + { + "valid": True, + "original_filename": "analyze.py", + "workspace_path": "/uploads/analyze.py", + "raw_workspace_path": "/uploads/analyze.py", + "ingest_mode": "passthrough", + "line_count": 12, + "image_count": 0, + } + ] + block = (await Impl.prepare_file_list(session_with(files), has_code_interpreter=True))[0] + + assert "path 与 raw 指向同一个文本原件" in block + # The dual-track wording ends in "do not read_file the original", which would + # be exactly wrong here. + assert "不要 read_file 二进制原件" not in block + assert "解析失败" not in block + + +async def test_pointer_block_keeps_one_explanation_paragraph(): + """All three kinds can co-occur; a paragraph each would bloat and contradict.""" + files = [ + { + "valid": True, + "original_filename": "book.xlsx", + "workspace_path": "/uploads/book.md", + "raw_workspace_path": "/uploads/book.xlsx", + "line_count": 5, + "image_count": 0, + }, + { + "valid": True, + "original_filename": "analyze.py", + "workspace_path": "/uploads/analyze.py", + "raw_workspace_path": "/uploads/analyze.py", + "ingest_mode": "passthrough", + "line_count": 12, + "image_count": 0, + }, + { + "valid": False, + "original_filename": "扫描件.pdf", + "workspace_path": "/uploads/扫描件.pdf", + "parsing_status": "failed", + }, + ] + block = (await Impl.prepare_file_list(session_with(files), has_code_interpreter=True))[0] + + assert block.count("说明:") == 1 + assert "raw 指向同名原件" in block + assert "path 与 raw 指向同一个文本原件" in block + assert "只有二进制原件" in block + + +async def test_passthrough_inside_a_folder_upload_keeps_the_tree(): + """Passthrough and folder upload compose: the raw mirror must carry the + sub-path, or the prefetch would land the file somewhere the pointer block + never mentioned.""" + minio = FakeMinio({}) + submit = SimpleNamespace(file_id="f12", file_name="Q1.py", relative_path="年报/2024/Q1.py") + + entry = await Impl._finalize_passthrough(submit, "Q1.py", "chat1", minio, b"print(1)\n", set()) + + assert entry["workspace_path"] == "/uploads/年报/2024/Q1.py" + assert entry["raw_workspace_path"] == entry["workspace_path"] + assert entry["raw_filename"] == "年报/2024/Q1.py" + assert "workspace/chat1/uploads/年报/2024/Q1.py" in minio.puts diff --git a/src/backend/test/linsight/test_workspace_glob_patterns.py b/src/backend/test/linsight/test_workspace_glob_patterns.py new file mode 100644 index 0000000000..022e502a87 --- /dev/null +++ b/src/backend/test/linsight/test_workspace_glob_patterns.py @@ -0,0 +1,127 @@ +"""``glob`` must match the patterns we ourselves put in the prompt. + +The folder-upload guidance (``workbench_impl.prepare_file_list``) tells the model, +verbatim and twice — once in the pointer header, once as the closing line of the +>40-file directory overview — to locate files with:: + + glob(如 "/uploads/**/*.xlsx") + +That exact spelling used to return zero matches, for two independent reasons: + +1. ``ls`` reports workspace paths with a leading slash (``/uploads/a/b.csv``) but + matching runs against the workspace-relative object key (``uploads/a/b.csv``), + and ``fnmatch`` is literal about that first character. So every absolute + pattern — the only kind the prompt teaches — missed everything. +2. ``fnmatch`` has no ``**``: it is just a ``*`` that crosses ``/``, so + ``uploads/**/*.csv`` requires at least one intermediate directory and skips a + file sitting directly in ``uploads/``. + +The failure mode is the worst kind: a silent empty result on a large folder, +where the overview block is the model's ONLY route to individual file names. The +same prompt then says 不要假设文件不存在 — which is precisely what an empty glob +invites. ``grep(glob=...)`` shares the comparison and so shared the bug. +""" + +from __future__ import annotations + +import tempfile + +import pytest + +from bisheng.linsight.domain.services.workspace_backend import WORKSPACE_PREFIX, WorkspaceBackend +from test.linsight.test_workspace_backend import FakeMinioStorage + +TREE = [ + "uploads/年报/2024/Q1.csv", + "uploads/年报/2024/Q2.csv", + "uploads/年报/notes.txt", + "uploads/附件/Q1.csv", + "uploads/top.csv", # directly under uploads/, no intermediate directory + "output/report.md", +] + + +@pytest.fixture() +def backend(): + minio = FakeMinioStorage() + for rel in TREE: + minio.store[(minio.bucket, f"{WORKSPACE_PREFIX}/sv1/{rel}")] = b"name,amount\nalpha,1\n" + with tempfile.TemporaryDirectory() as d: + yield WorkspaceBackend(svid="sv1", minio=minio, file_dir=d) + + +def _paths(result) -> set[str]: + return {m["path"] for m in (result.matches or [])} + + +# --------------------------------------------------------------------------- +# The pattern the prompt actually teaches +# --------------------------------------------------------------------------- +def test_absolute_pattern_from_the_prompt_matches(backend): + """REGRESSION: `/uploads/**/*.csv` returned 0 matches, so a model that + followed the folder-upload guidance concluded the files were not there.""" + got = _paths(backend.glob("/uploads/**/*.csv")) + + assert "/uploads/年报/2024/Q1.csv" in got + assert "/uploads/附件/Q1.csv" in got + # ** must span zero directories too, or a file sitting at the folder root is + # invisible to the one pattern the user was told finds everything. + assert "/uploads/top.csv" in got + assert "/output/report.md" not in got + + +def test_absolute_and_relative_spellings_agree(backend): + assert _paths(backend.glob("/uploads/**/*.csv")) == _paths(backend.glob("uploads/**/*.csv")) + + +def test_absolute_pattern_without_a_wildcard_directory(backend): + """`/output/*.md` is the shape the code-interpreter guidance produces.""" + assert _paths(backend.glob("/output/*.md")) == {"/output/report.md"} + + +def test_bare_extension_pattern_still_matches_by_basename(backend): + got = _paths(backend.glob("*.csv")) + assert "/uploads/年报/2024/Q1.csv" in got + assert "/uploads/top.csv" in got + + +def test_a_pattern_that_matches_nothing_still_matches_nothing(backend): + assert _paths(backend.glob("/uploads/**/*.xlsx")) == set() + assert _paths(backend.glob("/nope/**/*.csv")) == set() + + +# --------------------------------------------------------------------------- +# grep shares the comparison, and shared the bug +# --------------------------------------------------------------------------- +def test_grep_glob_filter_accepts_an_absolute_pattern(backend): + res = backend.grep("alpha", glob="/uploads/**/*.csv") + + assert res.error is None + hit_paths = {m.path if hasattr(m, "path") else m["path"] for m in (res.matches or [])} + assert "/uploads/年报/2024/Q1.csv" in hit_paths + assert "/output/report.md" not in hit_paths + + +def test_grep_without_a_glob_is_unfiltered(backend): + res = backend.grep("alpha") + assert res.error is None + hit_paths = {m.path if hasattr(m, "path") else m["path"] for m in (res.matches or [])} + assert "/output/report.md" in hit_paths + + +# --------------------------------------------------------------------------- +# The pattern-normalisation helper on its own +# --------------------------------------------------------------------------- +@pytest.mark.parametrize( + ("pattern", "expected"), + [ + ("/uploads/**/*.csv", ("uploads/**/*.csv", "uploads/*.csv")), + ("uploads/**/*.csv", ("uploads/**/*.csv", "uploads/*.csv")), + ("/output/*.md", ("output/*.md",)), + ("*.csv", ("*.csv",)), + ("/", ()), + ("", ()), + ], +) +def test_glob_pattern_candidates(pattern, expected): + assert WorkspaceBackend._glob_patterns(pattern) == expected diff --git a/src/backend/test/workflow/nodes/test_docx_replace_formatting.py b/src/backend/test/workflow/nodes/test_docx_replace_formatting.py new file mode 100644 index 0000000000..542259d361 --- /dev/null +++ b/src/backend/test/workflow/nodes/test_docx_replace_formatting.py @@ -0,0 +1,145 @@ +"""Report placeholders must keep the formatting the template author gave them. + +The replacement used to rebuild the whole paragraph from its plain text, which +dropped every run-level attribute (underline, bold, font) and the paragraph's own +style. Text values are now written into the run that holds the placeholder. +""" + +from io import BytesIO + +from docx import Document + +from bisheng.workflow.nodes.report.docx_replace import DocxReplacer + +PLACEHOLDER = "{{输入|input_bfa69.user_input}}" +KEY = "输入|input_bfa69.user_input" + + +def _render(build_template, variables) -> Document: + """Run a template through the replacer and return the rendered document.""" + template = BytesIO() + doc = Document() + build_template(doc) + doc.save(template) + + rendered = BytesIO() + DocxReplacer(BytesIO(template.getvalue())).replace_and_save(variables, rendered) + rendered.seek(0) + return Document(rendered) + + +def test_placeholder_run_formatting_survives(): + def build(doc): + paragraph = doc.add_paragraph() + paragraph.add_run("xxxxx. ") + underlined = paragraph.add_run(PLACEHOLDER) + underlined.underline = True + underlined.bold = True + paragraph.add_run(" xxxxx") + + rendered = _render(build, {KEY: [{"type": "text", "content": "有下划线"}]}) + runs = rendered.paragraphs[0].runs + + assert [run.text for run in runs] == ["xxxxx. ", "有下划线", " xxxxx"] + assert runs[1].underline is True + assert runs[1].bold is True + # The neighbours are untouched — they used to be re-stamped with run[0]'s format. + assert runs[0].underline is None + assert runs[2].underline is None + + +def test_placeholder_split_across_runs_is_replaced(): + """Word routinely splits a typed placeholder over several runs.""" + + def build(doc): + paragraph = doc.add_paragraph() + for chunk in ("{{输入|input_", "bfa69.user", "_input}}"): + run = paragraph.add_run(chunk) + run.underline = True + + rendered = _render(build, {KEY: [{"type": "text", "content": "拼接占位符"}]}) + paragraph = rendered.paragraphs[0] + + assert paragraph.text == "拼接占位符" + assert next(run for run in paragraph.runs if run.text).underline is True + + +def test_paragraph_style_survives(): + def build(doc): + paragraph = doc.add_paragraph(style="Quote") + paragraph.add_run(PLACEHOLDER) + + rendered = _render(build, {KEY: [{"type": "text", "content": "引用内容"}]}) + + assert rendered.paragraphs[0].style.name == "Quote" + assert rendered.paragraphs[0].text == "引用内容" + + +def test_value_items_inherit_placeholder_format_and_add_their_own(): + def build(doc): + paragraph = doc.add_paragraph() + run = paragraph.add_run(PLACEHOLDER) + run.underline = True + + rendered = _render( + build, + { + KEY: [ + {"type": "text", "content": "普通"}, + {"type": "text", "content": "加粗", "bold": True}, + ] + }, + ) + runs = [run for run in rendered.paragraphs[0].runs if run.text] + + assert [run.text for run in runs] == ["普通", "加粗"] + assert all(run.underline is True for run in runs) + assert runs[1].bold is True + assert runs[0].bold is None + + +def test_surrounding_text_in_the_same_run_is_kept(): + def build(doc): + run = doc.add_paragraph().add_run(f"前缀{PLACEHOLDER}后缀") + run.underline = True + + rendered = _render(build, {KEY: [{"type": "text", "content": "中间"}]}) + paragraph = rendered.paragraphs[0] + + assert paragraph.text == "前缀中间后缀" + assert all(run.underline is True for run in paragraph.runs if run.text) + + +def test_unresolved_placeholder_is_left_verbatim(): + def build(doc): + doc.add_paragraph().add_run(f"保留 {PLACEHOLDER}") + + rendered = _render(build, {"other": [{"type": "text", "content": "x"}]}) + + assert rendered.paragraphs[0].text == f"保留 {PLACEHOLDER}" + + +def test_block_content_still_splits_the_paragraph(): + """Tables cannot live inside a run, so those keep the rebuild path.""" + + def build(doc): + doc.add_paragraph().add_run(f"见下表:{PLACEHOLDER}") + + rendered = _render( + build, + { + KEY: [ + { + "type": "table", + "content": [ + [{"type": "text", "content": "列1"}, {"type": "text", "content": "列2"}], + [{"type": "text", "content": "值1"}, {"type": "text", "content": "值2"}], + ], + } + ] + }, + ) + + assert len(rendered.tables) == 1 + assert rendered.tables[0].rows[0].cells[0].text == "列1" + assert any("见下表:" in paragraph.text for paragraph in rendered.paragraphs) diff --git a/src/backend/test/workstation/test_daily_file_context_truncation.py b/src/backend/test/workstation/test_daily_file_context_truncation.py new file mode 100644 index 0000000000..36a397e0da --- /dev/null +++ b/src/backend/test/workstation/test_daily_file_context_truncation.py @@ -0,0 +1,103 @@ +"""A truncated attachment must be labelled as truncated. + +``_process_agent_files`` hard-cuts the extracted document text at +``ws_config.maxTokens`` CHARACTERS (the name says tokens; the slice does not) and +handed the prefix to the model with nothing marking the cut. A 1072-page tender +truncated to 15k characters therefore read as the whole document, and the model +answered questions about tables on page 400 — citing page numbers it had never +seen. + +The notice does not stop truncation; it tells the model the text is partial so it +can say so instead of inventing the rest. + +Everything below the cut is stubbed: no MinIO, no ETL, no model. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from bisheng.workstation.domain.services import chat_service + + +@pytest.fixture +def stub_pipeline(monkeypatch: pytest.MonkeyPatch): + """Feed ``_process_agent_files`` a canned document body of a chosen size.""" + + def _install(doc_text: str): + async def _fake_download(filepath): + return "/local/tender.pdf", "tender.pdf" + + async def _fake_extract(filepath, filename, invoke_user_id): + return doc_text + + async def _fake_covers(valid_files, downloaded_files): + return list(valid_files) + + monkeypatch.setattr(chat_service, "async_file_download", _fake_download) + monkeypatch.setattr(chat_service, "_extract_doc_text", _fake_extract) + monkeypatch.setattr(chat_service, "_annotate_agent_files_with_video_covers", _fake_covers) + + return _install + + +def _call_args(max_tokens: int): + data = SimpleNamespace(files=[{"file_id": "a", "filepath": "/bisheng/tender.pdf", "file_name": "tender.pdf"}]) + model_info = SimpleNamespace(visual=False) + login_user = SimpleNamespace(user_id=1) + ws_config = SimpleNamespace(maxTokens=max_tokens) + return data, model_info, login_user, ws_config + + +async def test_truncation_is_labelled_with_both_lengths(stub_pipeline): + stub_pipeline("x" * 40_000) + + file_context, _, _ = await chat_service._process_agent_files(*_call_args(15_000)) + + assert "[TRUNCATED]" in file_context + # The model is told what it has AND what it is missing. + assert "15000" in file_context + assert "40000" in file_context + # The body itself is still cut at the configured size. + assert file_context.startswith("x" * 15_000) + + +async def test_untruncated_content_is_untouched(stub_pipeline): + stub_pipeline("short document body") + + file_context, _, _ = await chat_service._process_agent_files(*_call_args(15_000)) + + assert file_context == "short document body" + assert "TRUNCATED" not in file_context + + +async def test_exactly_at_the_limit_is_not_labelled(stub_pipeline): + """Boundary: ``len == max`` loses nothing, so claiming truncation would lie.""" + stub_pipeline("y" * 100) + + file_context, _, _ = await chat_service._process_agent_files(*_call_args(100)) + + assert file_context == "y" * 100 + assert "TRUNCATED" not in file_context + + +async def test_one_char_over_the_limit_is_labelled(stub_pipeline): + stub_pipeline("y" * 101) + + file_context, _, _ = await chat_service._process_agent_files(*_call_args(100)) + + assert "[TRUNCATED]" in file_context + + +async def test_notice_survives_into_the_user_content_block(stub_pipeline): + """The notice is worthless unless it reaches the prompt the model reads.""" + stub_pipeline("z" * 40_000) + + file_context, _, _ = await chat_service._process_agent_files(*_call_args(15_000)) + content = chat_service._build_user_content(question="安装指导服务费部分有描述吗", file_context=file_context) + + assert "" in content + assert "[TRUNCATED]" in content + assert "安装指导服务费部分有描述吗" in content diff --git a/src/backend/test/workstation/test_daily_history_attachments.py b/src/backend/test/workstation/test_daily_history_attachments.py new file mode 100644 index 0000000000..6937204542 --- /dev/null +++ b/src/backend/test/workstation/test_daily_history_attachments.py @@ -0,0 +1,150 @@ +"""History replay must name the attachments of past turns. + +``get_chat_history`` parsed a question row as ``{"query": ...}`` and dropped the +sibling ``files`` list, so nothing in the replayed history said a file had ever +been attached. Combined with a code interpreter that cannot see the upload +either, the model had no signal that a document was in play — and a production +turn answered "KASAMA 与 NAKONDE 的培训是否相同" with zero tool calls, quoting +"Table 11-3, PDF 第 448-450 页" purely from its own earlier summary. + +Only NAMES are replayed. The extracted text was already truncated into that +turn's prompt and replaying it would blow ``history_max_tokens`` (default 8000); +the point is that the model knows a file exists so it can say it cannot re-read +it, not that it gets the content a second time. + +``ChatMessageDao.aget_messages_by_chat_id`` is patched; the unit under test is +``WorkStationService.get_chat_history``. +""" + +from __future__ import annotations + +import json +from datetime import datetime + +import pytest +from langchain_core.messages import HumanMessage + +from bisheng.database.models.message import ChatMessage, ChatMessageDao +from bisheng.workstation.domain.services.workstation_service import WorkStationService + + +def _question(message: str, extra: str = "{}") -> ChatMessage: + return ChatMessage( + id=1, + is_bot=False, + chat_id="chat-1", + user_id=1, + flow_id="", + type="over", + category="question", + message=message, + extra=extra, + tenant_id=1, + create_time=datetime(2026, 8, 12, 15, 49, 49), + update_time=datetime(2026, 8, 12, 15, 49, 49), + ) + + +@pytest.fixture +def patch_messages(monkeypatch: pytest.MonkeyPatch): + state: dict = {"rows": []} + + async def _fake(_cls, chat_id, categories=None, size=4): + return list(state["rows"]) + + monkeypatch.setattr(ChatMessageDao, "aget_messages_by_chat_id", classmethod(_fake)) + return state + + +def _human(history) -> str: + return "\n".join(str(m.content) for m in history if isinstance(m, HumanMessage)) + + +async def test_attachment_names_are_replayed(patch_messages): + payload = json.dumps( + { + "query": "帮我拆析标书里的 FAT 费用", + "files": [ + {"file_id": "a", "file_name": "ZTIP_RfB_NAKONDE.pdf"}, + {"file_id": "b", "file_name": "Part 2-Employer's Requirements.pdf"}, + ], + }, + ensure_ascii=False, + ) + patch_messages["rows"] = [_question(payload)] + + content = _human(await WorkStationService.get_chat_history("chat-1", max_tokens=None)) + + assert "帮我拆析标书里的 FAT 费用" in content + assert "ZTIP_RfB_NAKONDE.pdf" in content + assert "Part 2-Employer's Requirements.pdf" in content + + +async def test_no_attachments_adds_no_noise(patch_messages): + patch_messages["rows"] = [_question(json.dumps({"query": "你好", "files": []}, ensure_ascii=False))] + + content = _human(await WorkStationService.get_chat_history("chat-1", max_tokens=None)) + + assert content == "你好" + + +@pytest.mark.parametrize( + "file_item,expected", + [ + ({"file_name": "a.pdf", "filename": "b.pdf", "name": "c.pdf"}, "a.pdf"), + ({"filename": "b.pdf", "name": "c.pdf"}, "b.pdf"), + ({"name": "c.pdf"}, "c.pdf"), + # Task-mode ingest rows carry the original under yet another key. + ({"original_filename": "d.xlsx"}, "d.xlsx"), + ], +) +async def test_name_key_fallback_chain(patch_messages, file_item, expected): + """Daily uploads and task-mode ingests disagree on the key; try each in turn.""" + payload = json.dumps({"query": "q", "files": [file_item]}, ensure_ascii=False) + patch_messages["rows"] = [_question(payload)] + + content = _human(await WorkStationService.get_chat_history("chat-1", max_tokens=None)) + + assert expected in content + + +@pytest.mark.parametrize( + "files", + [ + "not-a-list", + [None, 42, "x"], + [{"file_id": "a"}], # present but nameless + [{"file_name": " "}], # whitespace only + ], +) +async def test_malformed_file_entries_do_not_break_history(patch_messages, files): + payload = json.dumps({"query": "q", "files": files}, ensure_ascii=False) + patch_messages["rows"] = [_question(payload)] + + content = _human(await WorkStationService.get_chat_history("chat-1", max_tokens=None)) + + assert content == "q" + + +async def test_legacy_plain_text_question_is_unaffected(patch_messages): + """Pre-2.5 rows hold bare text, not JSON — they must still replay verbatim.""" + patch_messages["rows"] = [_question("老格式的纯文本提问")] + + content = _human(await WorkStationService.get_chat_history("chat-1", max_tokens=None)) + + assert content == "老格式的纯文本提问" + + +async def test_rewritten_prompt_still_wins_but_keeps_attachments(patch_messages): + """``extra.prompt`` overrides the query text; the attachment list survives it.""" + payload = json.dumps( + {"query": "原始提问", "files": [{"file_name": "tender.pdf"}]}, + ensure_ascii=False, + ) + patch_messages["rows"] = [_question(payload, extra=json.dumps({"prompt": "被改写的提问"}))] + + content = _human(await WorkStationService.get_chat_history("chat-1", max_tokens=None)) + + assert "被改写的提问" in content + assert "原始提问" not in content + assert "tender.pdf" in content diff --git a/src/frontend/client/eslint-suppressions.json b/src/frontend/client/eslint-suppressions.json index f04fc9d6c7..d5401cd71c 100644 --- a/src/frontend/client/eslint-suppressions.json +++ b/src/frontend/client/eslint-suppressions.json @@ -601,9 +601,6 @@ "no-restricted-imports": { "count": 1 }, - "no-restricted-syntax": { - "count": 17 - }, "react-hooks/exhaustive-deps": { "count": 1 } diff --git a/src/frontend/client/src/api/apps.ts b/src/frontend/client/src/api/apps.ts index 19700451b8..9d7837c141 100644 --- a/src/frontend/client/src/api/apps.ts +++ b/src/frontend/client/src/api/apps.ts @@ -252,6 +252,8 @@ export async function uploadChatFile( onProgress, uploadMode?: 'linsight' | 'workstation', fileName?: string, + /** Aborts the request when the user removes the attachment mid-upload. */ + signal?: AbortSignal, ): Promise { const resolvedName = fileName ?? (file instanceof File ? file.name : 'upload'); const formData = new FormData(); @@ -273,6 +275,7 @@ export async function uploadChatFile( headers: { "Content-Type": "multipart/form-data" }, + signal, onUploadProgress: (progressEvent) => { // Calculate progress percentage if (progressEvent.total) { diff --git a/src/frontend/client/src/common/chatAccept.test.ts b/src/frontend/client/src/common/chatAccept.test.ts new file mode 100644 index 0000000000..1bf2e92fb1 --- /dev/null +++ b/src/frontend/client/src/common/chatAccept.test.ts @@ -0,0 +1,67 @@ +import { buildChatAccept, isFileNameAccepted } from "./chatAccept"; + +const DAILY = { enableMedia: false, enableEtl4lm: false, includeOfd: true }; + +describe("buildChatAccept", () => { + it("keeps daily chat on the document-only list", () => { + // Daily chat extracts attachment text into the prompt via the document + // parser, so an unparseable type fails the whole turn rather than being + // ignored. This string is a regression pin, not a preference. + expect(buildChatAccept(DAILY)).toBe( + ".pdf,.txt,.docx,.doc,.ppt,.pptx,.md,.html,.xls,.xlsx,.wps,.dps,.et,.ofd", + ); + }); + + it("is unchanged by taskMode:false", () => { + expect(buildChatAccept({ ...DAILY, taskMode: false })).toBe(buildChatAccept(DAILY)); + }); + + it("adds data/config/source files in task mode", () => { + const accept = buildChatAccept({ ...DAILY, taskMode: true }); + for (const ext of [".csv", ".py", ".json", ".yaml", ".sql", ".sh", ".ts", ".tsv"]) { + expect(accept.split(",")).toContain(ext); + } + // The document list survives alongside them. + expect(accept.split(",")).toContain(".pdf"); + }); + + it("still honours the image and media switches in task mode", () => { + const accept = buildChatAccept({ + enableMedia: true, + enableEtl4lm: true, + includeOfd: true, + taskMode: true, + }); + expect(accept.split(",")).toEqual(expect.arrayContaining([".png", ".mp3", ".mp4", ".py"])); + }); +}); + +describe("isFileNameAccepted", () => { + const accept = buildChatAccept({ ...DAILY, taskMode: true }); + + it("matches by extension, case-insensitively", () => { + expect(isFileNameAccepted("analyze.PY", accept)).toBe(true); + // Test data, not UI copy: a non-ASCII basename must still match on suffix, + // since the matcher lowercases the whole name before comparing. + // eslint-disable-next-line no-restricted-syntax + expect(isFileNameAccepted("数据表.csv", accept)).toBe(true); + }); + + it("rejects what the list does not name", () => { + expect(isFileNameAccepted("setup.exe", accept)).toBe(false); + expect(isFileNameAccepted("bundle.zip", accept)).toBe(false); + expect(isFileNameAccepted("", accept)).toBe(false); + }); + + it("drives the leave-task-mode cleanup against the daily list", () => { + const daily = buildChatAccept(DAILY); + expect(isFileNameAccepted("analyze.py", daily)).toBe(false); + expect(isFileNameAccepted("data.csv", daily)).toBe(false); + expect(isFileNameAccepted("report.pdf", daily)).toBe(true); + }); + + it("treats an empty or wildcard accept as no restriction", () => { + expect(isFileNameAccepted("anything.bin", "")).toBe(true); + expect(isFileNameAccepted("anything.bin", "*")).toBe(true); + }); +}); diff --git a/src/frontend/client/src/common/chatAccept.ts b/src/frontend/client/src/common/chatAccept.ts index d1859328dd..cbebf19880 100644 --- a/src/frontend/client/src/common/chatAccept.ts +++ b/src/frontend/client/src/common/chatAccept.ts @@ -10,10 +10,49 @@ const OFD_SUFFIX = '.ofd'; const MEDIA_ACCEPT = MEDIA_SUFFIXES.join(',').toLowerCase(); +/** + * Data / config / source files — TASK MODE ONLY. + * + * These have no document parser behind them and need none: task mode drops every + * attachment into a workspace where the agent reads them with `read_file` and the + * code interpreter opens them with pandas / json / sqlite. Gating them on "can the + * ETL parse it" was the wrong question for that surface. + * + * Daily chat deliberately does NOT get these: it has no workspace and no code + * interpreter, and its only way to use an attachment is to extract text into the + * prompt — which throws `ChatFileParseError` and fails the whole turn for a type + * the parser does not know. + * + * Keep in sync with the backend gate, + * `linsight/domain/services/workbench_impl.py::_PASSTHROUGH_TEXT_EXTS`. This list + * decides whether the user can pick the file; that set decides what happens to it. + */ +const TASK_MODE_DATA_ACCEPT = + '.csv,.tsv,.json,.jsonl,.xml,.yaml,.yml,.toml,.ini,.conf,.log,.sql,.py,.js,.ts,.sh'; + +/** + * Whether a file name matches an accept list by EXTENSION. + * + * Split out so the picker's gate and the "you just left task mode" cleanup share + * one matcher — the cleanup only has a stored attachment's name to work with, not + * a `File`, and a second hand-rolled matcher would drift from this one. + */ +export function isFileNameAccepted(fileName: string, accepts: string): boolean { + if (!accepts || accepts === '*') return true; + const lower = (fileName || '').toLowerCase(); + return accepts + .split(',') + .map((a) => a.trim().toLowerCase()) + .filter((a) => a.startsWith('.')) + .some((ext) => lower.endsWith(ext)); +} + export interface BuildChatAcceptOptions { enableMedia: boolean; enableEtl4lm: boolean; includeOfd: boolean; + /** Task mode additionally accepts data/config/source files. */ + taskMode?: boolean; } /** Runtime accept string for workbench chat file picker (replaces const enum). */ @@ -25,5 +64,8 @@ export function buildChatAccept(opts: BuildChatAcceptOptions): string { if (opts.enableMedia) { base = `${base},${MEDIA_ACCEPT}`; } + if (opts.taskMode) { + base = `${base},${TASK_MODE_DATA_ACCEPT}`; + } return base; } diff --git a/src/frontend/client/src/components/Chat/AiChatInput.tsx b/src/frontend/client/src/components/Chat/AiChatInput.tsx index a7e3211fe9..8fad3a8fa8 100644 --- a/src/frontend/client/src/components/Chat/AiChatInput.tsx +++ b/src/frontend/client/src/components/Chat/AiChatInput.tsx @@ -14,7 +14,7 @@ import { } from "react"; import { useNavigate } from "react-router-dom"; import { useRecoilValue, useRecoilState } from "recoil"; -import { buildChatAccept } from "~/common/chatAccept"; +import { buildChatAccept, isFileNameAccepted } from "~/common/chatAccept"; import { SkillSelector } from "~/components/Linsight/Input/SkillSelector"; import { taskModeSkillsState } from "~/store/linsight"; import AgentToolSelector from "~/components/Chat/Input/AgentToolSelector"; @@ -30,6 +30,7 @@ import SpeechToTextComponent from "~/components/Voice/SpeechToText"; import { useContainerCompact, TOOLBAR_COMPACT_THRESHOLD } from "~/hooks"; import { useGetWorkbenchModelsQuery } from "~/hooks/queries/data-provider"; import useLocalize from "~/hooks/useLocalize"; +import { useToastContext } from "~/Providers"; import InputFiles from "~/pages/appChat/components/InputFiles"; import { resolveUploadSizeLimits } from "~/pages/knowledge/knowledgeUtils"; import { useFileDropAndPaste } from "~/pages/appChat/useFileDropAndPaste"; @@ -50,6 +51,20 @@ export interface AiChatInputFeatures { taskMode?: boolean; } +/** + * The subset of an attachment the leave-task-mode cleanup needs: a display name + * (whichever of the four spellings this list happens to use) and an id to remove + * it by. Kept structural so it fits both the committed `chatFiles` entries and + * the in-flight `uploadingFiles` ones. + */ +interface DroppableAttachment { + clientId?: string; + id?: string; + name?: string; + file_name?: string; + filename?: string; +} + interface AiChatInputProps { size?: '' | 'mini'; features?: AiChatInputFeatures; @@ -144,6 +159,7 @@ const AiChatInput = memo( onToggleTaskMode, }: AiChatInputProps) => { const localize = useLocalize(); + const { showToast } = useToastContext(); const { modelSelect = true, knowledgeBase = true, @@ -170,18 +186,9 @@ const AiChatInput = memo( // selection is refilled as a chip. Keyed 'new' to match the landing page. const [dailySkills, setDailySkills] = useRecoilState(taskModeSkillsState('new')); - // Exiting task mode discards the skill selection so the panel's checkboxes - // reset in sync with the (now-hidden) skill chips. Track the previous value - // to fire only on a true→false transition, not on mount or re-entry. // True while an IME composition is in flight — see handleKeyDown. const isComposingRef = useRef(false); const prevTaskModeRef = useRef(taskMode); - useEffect(() => { - if (prevTaskModeRef.current && !taskMode) { - setDailySkills((prev) => (prev.length ? [] : prev)); - } - prevTaskModeRef.current = taskMode; - }, [taskMode, setDailySkills]); const isControlled = externalValue !== undefined; const [internalText, setInternalText] = useState(""); @@ -252,6 +259,47 @@ const AiChatInput = memo( }>>([]); const inputFilesRef = useRef(null); + // Leaving task mode drops what only task mode could carry: the skill + // selection (so the panel's checkboxes reset in sync with the now-hidden + // chips) and any attachment daily chat cannot accept. The latter is not + // tidiness — daily chat feeds attachments through the document parser, so a + // leftover .py would fail the whole turn instead of just being ignored. + // Fires only on a true→false transition, never on mount or re-entry. + useEffect(() => { + if (prevTaskModeRef.current && !taskMode) { + setDailySkills((prev) => (prev.length ? [] : prev)); + + const dailyAccept = buildChatAccept({ + enableMedia: !!envConfig?.enable_media_upload, + enableEtl4lm: !!bsConfig?.enable_etl4lm, + includeOfd: !isLingsi, + }); + const isKept = (name: string) => isFileNameAccepted(name || "", dailyAccept); + const dropped: string[] = []; + const collect = (f: DroppableAttachment) => { + const name = f?.name || f?.file_name || f?.filename || ""; + if (isKept(name)) return true; + dropped.push(name); + // Drop it inside InputFiles too, otherwise its own list would keep + // re-publishing the file through onFilesStateChange. + inputFilesRef.current?.removeByClientId?.(f?.clientId ?? f?.id); + return false; + }; + setChatFiles((prev) => (prev ? prev.filter(collect) : prev)); + setUploadingFiles((prev) => prev.filter(collect)); + if (dropped.length) { + showToast?.({ + message: localize("com_task_mode_files_dropped", { 0: dropped.join("、") }), + status: "warning", + }); + } + } + prevTaskModeRef.current = taskMode; + // envConfig/bsConfig are read only when the transition fires; leaving them + // out keeps a config refetch from re-running the cleanup. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [taskMode, setDailySkills]); + // Voice input: check if ASR model is available const { data: modelData } = useGetWorkbenchModelsQuery(); const showVoice = voiceInput && !!modelData?.asr_model?.id; @@ -276,6 +324,9 @@ const AiChatInput = memo( // Drag & paste file support (only when not disabled by exclusion) const { isDragging, handlePaste } = useFileDropAndPaste({ enabled: showUpload && !disabled && !filesDisabled, + // Task mode only: a dropped directory is expanded with its tree + // preserved. Daily chat has no workspace to rebuild a tree in. + allowFolders: taskMode, onFilesReceived: (files: FileList | File[]) => { inputFilesRef.current?.upload(files); }, @@ -391,8 +442,10 @@ const AiChatInput = memo( kbs={selectedOrgKbs} skills={taskMode ? dailySkills : []} onRemoveFile={(file) => { - inputFilesRef.current?.removeByName?.(file.name); - setChatFiles((prev) => (prev || []).filter((i) => i.name !== file.name)); + // clientId, not name: a folder upload can carry the + // same file name in several subdirectories. + inputFilesRef.current?.removeByClientId?.(file.clientId); + setChatFiles((prev) => (prev || []).filter((i) => String(i.clientId) !== String(file.clientId))); }} onRemoveKb={onSelectedOrgKbsChange ? (kb) => { onSelectedOrgKbsChange(selectedOrgKbs.filter((i) => i.id !== kb.id)); @@ -409,6 +462,10 @@ const AiChatInput = memo( enableMedia: !!envConfig?.enable_media_upload, enableEtl4lm: !!bsConfig?.enable_etl4lm, includeOfd: !isLingsi, + // Task mode also takes data/config/source files: it has a + // workspace and a code interpreter to use them with. Daily + // chat has neither, and would fail the turn on parse. + taskMode, }); return { @@ -428,6 +486,8 @@ const AiChatInput = memo( id: String(f.id), clientId: String(f.id), name: String(f.name || ""), + // Drives the folder chip grouping in AttachmentBar. + ...(f.relativePath ? { relative_path: f.relativePath } : {}), ...(f.previewUrl ? { previewUrl: f.previewUrl } : {}), ...(f.mediaPreviewUrl ? { mediaPreviewUrl: f.mediaPreviewUrl } : {}), ...(f.mediaCoverUrl ? { mediaCoverUrl: f.mediaCoverUrl } : {}), @@ -446,6 +506,12 @@ const AiChatInput = memo( name: f.name, filename: f.name, file_name: f.name, + // Folder upload: kept for the chip grouping AND threaded + // to the backend so the workspace rebuilds the tree. + relative_path: f.relativePath && f.relativePath !== f.name + ? f.relativePath + : undefined, + size: f.size, parsing_status: f.parsingStatus || 'completed', parsingState: f.parsingStatus && !['completed', 'failed'].includes(f.parsingStatus) @@ -531,6 +597,11 @@ const AiChatInput = memo( showFileUpload={showUpload} fileUploadDisabled={filesDisabled} onFileUploadClick={() => inputFilesRef.current?.openPicker?.()} + // Folder upload is task-mode only: daily chat has no agent + // workspace to rebuild the directory tree in. + onFolderUploadClick={taskMode + ? () => inputFilesRef.current?.openFolderPicker?.() + : undefined} // Task mode toggle present in both modes (plan-mode style). // Gated by the caller's taskModeEntry feature (role permission in // ChatView); the legacy global `linsight_entry` switch was retired diff --git a/src/frontend/client/src/components/Chat/ChatView.tsx b/src/frontend/client/src/components/Chat/ChatView.tsx index 2efe28ae60..b8b9e0d14b 100644 --- a/src/frontend/client/src/components/Chat/ChatView.tsx +++ b/src/frontend/client/src/components/Chat/ChatView.tsx @@ -7,6 +7,7 @@ import { useRecoilState } from 'recoil'; import { getRecommendedAppsApi } from '~/api/apps'; import { writeAppChatOrigin, writeAppChatReturnTo } from '~/pages/appChat/appChatOrigin'; import AiChatInput from '~/components/Chat/AiChatInput'; +import { resolveTaskModeOnNavigation } from '~/components/Chat/resolveTaskMode'; import AiChatMessages from '~/components/Chat/AiChatMessages'; import { PinnedTaskPanel } from '~/components/Linsight/Execution/PinnedTaskPanel'; import { WorkspacePanel } from '~/components/Linsight/Artifacts/WorkspacePanel'; @@ -251,47 +252,16 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index? // F035: distinguishes the post-submit URL self-rewrite (same conversation // just got its real id — keep the composing mode) from a genuine navigation - // to a different existing conversation (drop task mode). Set right before the - // self-rewrite navigate below, consumed by the reset effect it triggers. + // to a different existing conversation (derive the mode from its history). + // Set right before the self-rewrite navigate below, consumed by the mode + // effect once the rewritten URL has landed. const keepTaskModeOnRewriteRef = useRef(false); - - // F035: sync the local task-mode toggle to navigation. ChatView is NOT - // remounted across `/c/:id` param changes (same route element), so the - // useState initializer above only runs on first mount. This effect picks up - // subsequent navigations: - // - sidebar "新建任务" lands on /c/new with state.taskMode=true → enter task mode. - // - the first submit on /c/new self-rewrites the URL to the real id; that is - // the SAME conversation, so the user's chosen mode is preserved (they can - // keep composing task turns, or toggle off manually). - // - any OTHER navigation to an existing conversation (id !== 'new') defaults - // to off HERE, before history loads (so a stale mode from another tab can't - // leak in). The restore effect below re-enters task mode once the loaded - // history proves this is a task conversation — a daily chat stays off. - // location.key changes on every navigation so re-entering /c/new with the - // same state still re-triggers. - useEffect(() => { - if (conversationId !== 'new') { - // Post-submit self-rewrite: keep whatever mode the user is composing in. - if (keepTaskModeOnRewriteRef.current) { - keepTaskModeOnRewriteRef.current = false; - return; - } - setTaskMode(false); - return; - } - // On /c/new both sidebar entries set the atom themselves before navigating - // ("新建任务" → true, "新建对话" → false), so this only has to honour a - // navigation that actually declares a mode. Reading an absent state as - // "daily" used to drop the user's choice: `newConversation` runs an async - // chain that fires its own state-less `navigate('/c/new')` a tick after - // ours, and that second landing reset the toggle the button had just set — - // the intermittent "新建任务 opens a daily chat, click it again and it works". - const navTaskMode = (location.state as { taskMode?: boolean } | null)?.taskMode; - if (navTaskMode !== undefined) { - setTaskMode(!!navTaskMode); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [location.key, conversationId]); + // Set when the user flips the toggle by hand. A manual choice has to survive + // the navigations that happen WITHIN one conversation (see the mode effect + // below, which otherwise re-derives the mode from the loaded history on every + // navigation); it is cleared as soon as the conversation itself changes. + const userToggledRef = useRef(false); + const lastConversationIdRef = useRef(conversationId); // Sync URL: ONLY when we were on /new and the hook just assigned a real ID. // Do NOT navigate if the user is clicking around in the sidebar (that changes @@ -420,18 +390,64 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index? [messages, conversationId], ); - // F035: restore task mode when returning to a task conversation. The reset - // effect above defaults every existing conversation to off before its history - // loads (so a stale mode from another tab doesn't leak in); this re-enables - // the toggle once the history resolves and proves this is a task conversation, - // so the user can keep composing task turns without re-toggling. Not gated on - // the toggle itself — an explicit manual off within a visit stays off (it - // doesn't change these deps), but a fresh navigation back re-enters task mode. + // F035: sync the task-mode toggle to navigation. ChatView is NOT remounted + // across `/c/:id` param changes (same route element), so the atom's initial + // value only applies on first mount. This effect picks up subsequent + // navigations: + // - sidebar "新建任务" lands on /c/new with state.taskMode=true → enter task mode. + // - the first submit on /c/new self-rewrites the URL to the real id; that is + // the SAME conversation, so the user's chosen mode is preserved (they can + // keep composing task turns, or toggle off manually). + // - any OTHER navigation to an existing conversation (id !== 'new') DERIVES + // the mode from that conversation's own history (isTaskConversation), so a + // stale mode from another tab can't leak in AND a task conversation keeps + // its mode. + // Deriving and resetting MUST stay in one effect. They used to be two: the + // reset (`setTaskMode(false)`) ran on every location.key change, while the + // restore's deps — [conversationId, isTaskConversation, canUseTaskMode] — are + // all constant within a visit, so it could never fire a second time. A single + // in-conversation navigation (e.g. the sidebar 首页 link, whose target IS the + // current pathname) therefore parked the toggle on off for good, and + // `taskMode || taskRunning` below kept the button lit until the running task + // ended — so the drop was invisible and the NEXT turn silently went daily. + // Placed after isTaskConversation on purpose: it is in the dep array. useEffect(() => { - if (conversationId === 'new') return; - if (isTaskConversation && canUseTaskMode) setTaskMode(true); + // A manual toggle only binds to the conversation it was made in. + if (lastConversationIdRef.current !== conversationId) { + lastConversationIdRef.current = conversationId; + userToggledRef.current = false; + } + // Only consumable once we are ON the rewritten URL. This effect is declared + // AFTER the effect that raises the flag (it has to be — isTaskConversation is + // in its dep array), so within the commit that raises it we would otherwise + // eat the flag while still on /c/new and then derive `false` on the real + // landing, knocking the user out of the mode they just submitted in. + const isSelfRewrite = conversationId !== 'new' && keepTaskModeOnRewriteRef.current; + if (isSelfRewrite) { + keepTaskModeOnRewriteRef.current = false; + } + + const next = resolveTaskModeOnNavigation({ + conversationId, + isTaskConversation, + canUseTaskMode, + navTaskMode: (location.state as { taskMode?: boolean } | null)?.taskMode, + isSelfRewrite, + userToggled: userToggledRef.current, + }); + if (next !== null) { + setTaskMode(next); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [conversationId, isTaskConversation, canUseTaskMode]); + }, [location.key, conversationId, isTaskConversation, canUseTaskMode]); + + // Flipping the toggle by hand pins the mode for the rest of this conversation + // — without the flag, the effect above would re-derive it from the history on + // the next navigation and undo the user's choice. + const handleToggleTaskMode = useCallback(() => { + userToggledRef.current = true; + setTaskMode((v) => !v); + }, [setTaskMode]); // F035: workspace drawer for the chat-embedded task mode. Lifted to ChatView // (the task turn renders inline per message, but the entry button lives in the @@ -702,7 +718,7 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index? // ClarifyCard in the stream above, not this input. taskRunning={taskRunning && !awaitingUserInput} features={{ taskModeEntry: canUseTaskMode, taskMode: (taskMode || taskRunning) && canUseTaskMode }} - onToggleTaskMode={() => setTaskMode((v) => !v)} + onToggleTaskMode={handleToggleTaskMode} placeholder={awaitingUserInput ? t('com_linsight_awaiting_reply') : taskMode @@ -874,7 +890,7 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index? isStreaming={isStreaming} isParsingMedia={isParsingMedia} features={{ taskModeEntry: canUseTaskMode, taskMode: taskMode && canUseTaskMode }} - onToggleTaskMode={() => setTaskMode((v) => !v)} + onToggleTaskMode={handleToggleTaskMode} placeholder={taskMode ? ((bsConfig as any)?.linsightConfig?.input_placeholder || t('com_linsight_input_placeholder')) : undefined} diff --git a/src/frontend/client/src/components/Chat/Input/AttachmentBar.tsx b/src/frontend/client/src/components/Chat/Input/AttachmentBar.tsx index 2cea4aad9d..3baa7b29b5 100644 --- a/src/frontend/client/src/components/Chat/Input/AttachmentBar.tsx +++ b/src/frontend/client/src/components/Chat/Input/AttachmentBar.tsx @@ -29,6 +29,8 @@ import { isMediaChipFile, MediaAttachmentChip } from "~/components/Chat/attachme import { FileUploadThumbnail } from "~/components/Chat/attachments/UploadAttachmentThumbnail"; import { isMediaAttachmentFile } from "~/utils/mediaAttachmentUtils"; import { resolveKnowledgePreviewUrl } from "~/pages/knowledge/FilePreview/previewUrlUtils"; +import { groupAttachmentsByFolder } from "~/components/Linsight/Input/ContextChips"; +import { useLocalize } from "~/hooks"; /** Fixed card geometry from the design (Figma 12841:47405). */ const CARD_WIDTH = 148; @@ -152,6 +154,28 @@ const FileCard = ({ file, onRemove }: { file: any; onRemove?: () => void }) => { ); }; +/** One card for a whole uploaded directory (see `groupAttachmentsByFolder`). */ +const FolderCard = ({ + folderName, + fileCount, + onRemove, +}: { + folderName: string; + fileCount: number; + onRemove?: () => void; +}) => { + const localize = useLocalize(); + const label = `${folderName} (${localize('com_folder_upload_file_count', { 0: fileCount })})`; + return ( + } + label={label} + title={label} + onRemove={onRemove} + /> + ); +}; + const SkillCard = ({ skill, onRemove }: { skill: any; onRemove?: () => void }) => ( } @@ -224,6 +248,8 @@ interface AttachmentBarProps { mediaCoverUrl?: string; cover_filepath?: string; mediaDurationSec?: number; + /** Folder upload: path relative to the picked folder root. */ + relative_path?: string; }>; files: any[]; kbs: any[]; @@ -236,6 +262,7 @@ interface AttachmentBarProps { type Entry = | { kind: "uploading"; key: string; data: AttachmentBarProps['uploadingFiles'][number] } | { kind: "file"; key: string; data: any } + | { kind: "folder"; key: string; data: { folderName: string; files: unknown[]; isUploading: boolean } } | { kind: "kb"; key: string; data: any } | { kind: "skill"; key: string; data: any }; @@ -255,23 +282,60 @@ export const AttachmentBar = ({ const [canRight, setCanRight] = useState(false); const entries = useMemo(() => { - const completedNames = new Set( - files - .map((f) => f.name || f.file_name || f.filename) - .filter(Boolean), + // Match uploading→completed by client id, not by name: a folder upload + // routinely carries the same file name in several subdirectories, and a + // name-keyed set would hide sibling cards that are still uploading. + const completedIds = new Set( + files.map((f) => String(f.clientId ?? f.id ?? '')).filter(Boolean), ); - const activeUploads = uploadingFiles.filter((f) => !completedNames.has(f.name)); - const all: Entry[] = [ + const activeUploads = uploadingFiles.filter( + (f) => !completedIds.has(String(f.clientId ?? f.id ?? '')), + ); + + // Folder upload: one card per picked DIRECTORY. A folder is capped at 100 + // files, and a card each would turn this strip into a scroll marathon + // where removing the folder costs a hundred clicks. + const fileLike = [ ...activeUploads.map((f) => ({ - kind: "uploading" as const, - key: attachmentSeqKey(f.id) ?? `up-${f.id}`, - data: f, + clientId: String(f.clientId ?? f.id), + name: f.name, + isUploading: true, + relative_path: f.relative_path, + __kind: "uploading" as const, + __data: f, + __key: attachmentSeqKey(f.id) ?? `up-${f.id}`, })), ...files.map((f) => ({ - kind: "file" as const, - key: attachmentSeqKey(f.clientId || f.id) ?? `file-${f.file_id || f.filepath || f.name}`, - data: f, + clientId: String(f.clientId ?? f.id ?? f.file_id ?? f.name), + name: f.name || f.file_name || f.filename || '', + isUploading: false, + relative_path: f.relative_path, + __kind: "file" as const, + __data: f, + __key: attachmentSeqKey(f.clientId || f.id) ?? `file-${f.file_id || f.filepath || f.name}`, })), + ]; + + const fileEntries: Entry[] = groupAttachmentsByFolder(fileLike).map((group) => { + if (group.folderName) { + return { + kind: "folder" as const, + key: `folder-${group.folderName}`, + data: { + folderName: group.folderName, + files: group.files.map((f) => f.__data), + isUploading: group.isUploading, + }, + }; + } + const only = group.files[0]; + return only.__kind === "uploading" + ? { kind: "uploading" as const, key: only.__key, data: only.__data } + : { kind: "file" as const, key: only.__key, data: only.__data }; + }); + + const all: Entry[] = [ + ...fileEntries, // Knowledge selections are stored newest-first (the picker prepends), // the opposite of the file arrays. Feed them in oldest-first so the // sequence below means the same thing for every source: without this, @@ -360,7 +424,7 @@ export const AttachmentBar = ({ file={entry.data} onRemove={ onRemoveFile - ? () => onRemoveFile({ name: entry.data.name }) + ? () => onRemoveFile(entry.data) : undefined } /> @@ -373,6 +437,19 @@ export const AttachmentBar = ({ onRemove={onRemoveFile ? () => onRemoveFile(entry.data) : undefined} /> ); + case "folder": + return ( + entry.data.files.forEach((f) => onRemoveFile(f)) + : undefined + } + /> + ); case "kb": return ( void; + /** Render an "上传文件夹" entry below it. Task mode only — omit to hide it. */ + onFolderUploadClick?: () => void; /** F035 (PRD §4.1.3): render the "任务模式" entry as a separate group at the * bottom of the "+" menu. Daily chat → navigates to /linsight; routing is * delegated to the caller so this component stays route-free. */ @@ -495,6 +498,24 @@ export const ChatKnowledge = ({ )} + {/* Upload folder — only rendered when the caller supplies a handler, i.e. + task mode. Daily chat has no agent workspace to rebuild a tree in. */} + {variant === 'plus' && showFileUpload && onFolderUploadClick + && ((!isMobile) || (isMobile && mobilePanel === 'root')) && ( + { + e.preventDefault(); + if (fileUploadDisabled) return; + onFolderUploadClick(); + }} + className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-[5px] outline-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-40" + > + + {localize('com_ui_upload_folder')} + + )} + {/* Knowledge pill (mobile): show the SPACES list directly — no drill. Matches the desktop layout (title + list) so both surfaces feel the same; only the outer width / position adapt to the smaller screen. */} diff --git a/src/frontend/client/src/components/Chat/Messages/Content/CitationDocumentPreviewDrawer.tsx b/src/frontend/client/src/components/Chat/Messages/Content/CitationDocumentPreviewDrawer.tsx index 8110db8f0f..dd47fa4c10 100644 --- a/src/frontend/client/src/components/Chat/Messages/Content/CitationDocumentPreviewDrawer.tsx +++ b/src/frontend/client/src/components/Chat/Messages/Content/CitationDocumentPreviewDrawer.tsx @@ -10,10 +10,11 @@ import { cn } from '~/utils'; import { getCitationDocumentFileType, getCitationDocumentName, - getCitationDocumentUrl, getCitationItemBBoxes, + isMediaCitation, isRagCitation, - resolveCitationDocumentUrl, + resolveCitationDocumentUrls, + resolveCitationDownloadUrl, toAbsolutePreviewUrl, type CitationPdfBBox, } from './citationUtils'; @@ -66,13 +67,20 @@ export function CitationDocumentPreviewContent({ const itemId = preview?.itemId; const locateChunk = preview?.locateChunk; const fileName = detail ? getCitationDocumentName(detail) : ''; - const rawFileUrl = detail ? getCitationDocumentUrl(detail) : ''; - const [resolvedRawFileUrl, setResolvedRawFileUrl] = useState(rawFileUrl); + const isMedia = isMediaCitation(detail); + const [resolvedUrls, setResolvedUrls] = useState<{ originalUrl: string; previewUrl: string }>({ + originalUrl: '', + previewUrl: '', + }); const [isResolvingFileUrl, setIsResolvingFileUrl] = useState(false); - const fileType = canRenderPreview - ? resolveFileType(detail as ChatCitation, resolvedRawFileUrl || rawFileUrl) - : ''; - const fileUrl = toAbsolutePreviewUrl(resolvedRawFileUrl || rawFileUrl); + // A clip renders from the original file (the player) with its transcript + // alongside; everything else renders from the derived preview. + const rawViewerUrl = isMedia + ? resolvedUrls.originalUrl || resolvedUrls.previewUrl + : resolvedUrls.previewUrl || resolvedUrls.originalUrl; + const fileType = canRenderPreview ? resolveFileType(detail as ChatCitation, rawViewerUrl) : ''; + const fileUrl = toAbsolutePreviewUrl(rawViewerUrl); + const transcriptUrl = isMedia ? toAbsolutePreviewUrl(resolvedUrls.previewUrl) : ''; const shouldLocateChunk = !!locateChunk && fileType === 'pdf'; const bboxes: CitationPdfBBox[] = shouldLocateChunk ? getCitationItemBBoxes(detail as ChatCitation, itemId) @@ -81,7 +89,7 @@ export function CitationDocumentPreviewContent({ useEffect(() => { let active = true; - setResolvedRawFileUrl(rawFileUrl); + setResolvedUrls({ originalUrl: '', previewUrl: '' }); if (!canRenderPreview || !detail) { setIsResolvingFileUrl(false); @@ -90,24 +98,17 @@ export function CitationDocumentPreviewContent({ }; } - if (rawFileUrl) { - setIsResolvingFileUrl(false); - return () => { - active = false; - }; - } - setIsResolvingFileUrl(true); - void resolveCitationDocumentUrl(detail as ChatCitation).then((nextUrl) => { + void resolveCitationDocumentUrls(detail as ChatCitation).then((nextUrls) => { if (!active) return; - setResolvedRawFileUrl(nextUrl || ''); + setResolvedUrls(nextUrls); setIsResolvingFileUrl(false); }); return () => { active = false; }; - }, [canRenderPreview, detail, rawFileUrl]); + }, [canRenderPreview, detail]); if (!canRenderPreview) { return null; @@ -120,6 +121,7 @@ export function CitationDocumentPreviewContent({ fileName={fileName} fileType={fileType} fileUrl={fileUrl} + transcriptUrl={transcriptUrl} highlightBboxes={bboxes} targetBBox={targetBBox} compactMode={compactMode} @@ -149,7 +151,9 @@ export default function CitationDocumentPreviewDrawer({ const setChatMobileNavHidden = useSetRecoilState(store.chatMobileNavHiddenState); const detail = preview?.detail ?? null; const fileName = getCitationDocumentName(detail); - const [resolvedRawFileUrl, setResolvedRawFileUrl] = useState(() => getCitationDocumentUrl(detail)); + // Download always hands back the file the user uploaded, never the derived + // preview — otherwise a clip downloads as its transcript under an .mp4 name. + const [resolvedRawFileUrl, setResolvedRawFileUrl] = useState(''); const fileUrl = toAbsolutePreviewUrl(resolvedRawFileUrl); const canRenderPreview = !!preview && isRagCitation(preview.detail); @@ -197,14 +201,7 @@ export default function CitationDocumentPreviewDrawer({ useEffect(() => { let active = true; - const nextRawFileUrl = getCitationDocumentUrl(detail); - setResolvedRawFileUrl(nextRawFileUrl); - - if (nextRawFileUrl) { - return () => { - active = false; - }; - } + setResolvedRawFileUrl(''); if (!detail || !isRagCitation(detail)) { return () => { @@ -212,7 +209,7 @@ export default function CitationDocumentPreviewDrawer({ }; } - void resolveCitationDocumentUrl(detail).then((nextUrl) => { + void resolveCitationDownloadUrl(detail).then((nextUrl) => { if (!active) return; setResolvedRawFileUrl(nextUrl || ''); }); @@ -227,7 +224,7 @@ export default function CitationDocumentPreviewDrawer({ } const handleDownload = async () => { - const nextFileUrl = toAbsolutePreviewUrl(resolvedRawFileUrl || await resolveCitationDocumentUrl(detail)); + const nextFileUrl = toAbsolutePreviewUrl(resolvedRawFileUrl || await resolveCitationDownloadUrl(detail)); setResolvedRawFileUrl((current) => current || nextFileUrl); if (!nextFileUrl) return; const link = document.createElement('a'); diff --git a/src/frontend/client/src/components/Chat/Messages/Content/CitationReferencesDrawer.tsx b/src/frontend/client/src/components/Chat/Messages/Content/CitationReferencesDrawer.tsx index 99ac5f5dd9..c6c5c6fe78 100644 --- a/src/frontend/client/src/components/Chat/Messages/Content/CitationReferencesDrawer.tsx +++ b/src/frontend/client/src/components/Chat/Messages/Content/CitationReferencesDrawer.tsx @@ -4,6 +4,7 @@ import { Outlined } from 'bisheng-icons'; import { useSetRecoilState } from 'recoil'; import { getCitationDetail, resolveCitationDetails, type ChatCitation } from '~/api/chatApi'; import { useLocalize, useMediaQuery, usePrefersMobileLayout } from '~/hooks'; +import { useToastContext } from '~/Providers'; import store from '~/store'; import { cn } from '~/utils'; import { @@ -15,7 +16,7 @@ import { getCitationDocumentUrl, isRagCitation, normalizeCitationType, - resolveCitationDocumentUrl, + resolveCitationDownloadUrl, toAbsolutePreviewUrl, type CitationPreview, type CitationReferenceItem, @@ -63,6 +64,7 @@ type CitationDesktopView = 'list' | 'document-preview'; const CITATION_PANEL_EXPANDED_BREAKPOINT = 768; function SourceTypeBadge({ preview, type }: { preview: CitationPreview | null; type?: string }) { + const localize = useLocalize(); const isWeb = normalizeCitationType(preview?.type || type) === 'web'; return (
- {isWeb ? '网页' : '文档'} + {isWeb ? localize('com_citation.web') : localize('com_citation.document')}
); } @@ -109,10 +111,11 @@ function CitationReferenceCard({ hasError: boolean; onOpenDocumentPreview: (item: CitationReferenceItem, detail: ChatCitation) => void; }) { + const localize = useLocalize(); const preview = item.legacyPreview ?? buildCitationDocumentPreview(detail, item.data); const type = preview?.type || item.data.type; const isWeb = normalizeCitationType(type) === 'web'; - const title = preview?.title || '暂无标题'; + const title = preview?.title || localize('com_citation.untitled'); const canOpenDocument = !!detail && isRagCitation(detail, type); const { name: documentName, extension: documentExtension } = splitDocumentTitle(title, detail, preview); @@ -174,10 +177,10 @@ function CitationReferenceCard({ {isLoading ? ( - 加载溯源详情... + {localize('com_citation.loading_detail')} ) : hasError ? ( - 溯源详情加载失败 + {localize('com_citation.load_detail_failed')} ) : ( null )} @@ -189,13 +192,13 @@ function CitationReferenceCard({
- {preview?.sourceName || '网页'} + {preview?.sourceName || localize('com_citation.web')} {preview?.sourceMeta ? {preview.sourceMeta} : null} ) : ( <> - {preview?.sourceName || '政策文件'} + {preview?.sourceName || localize('com_citation.policy_document')} )} @@ -222,7 +225,8 @@ export default function CitationReferencesDrawer({ onDesktopViewChange, }: CitationReferencesDrawerProps) { const localize = useLocalize(); - // <=768: 走抽屉(不内联分栏);<=576: 抽屉全屏覆盖 + const { showToast } = useToastContext(); + // <=768: use the drawer (no inline split); <=576: drawer covers full screen const isNarrowLayout = usePrefersMobileLayout(); const isPhoneViewport = useMediaQuery('(max-width: 576px)'); const isFullBleedMobile = isPhoneViewport; @@ -403,7 +407,8 @@ export default function CitationReferencesDrawer({ const isOpen = panelOnly ? true : isDesktopInlinePanel ? !!open : internalOpen; const isDesktopPreviewInline = isDesktopInlinePanel && desktopView === 'document-preview' && !!documentPreview; - // 仅全屏参考资料(≤576)隐藏 MobileNav;平板窄屏保留顶栏标题,抽屉 z-[120] 已高于 MobileNav z-[60] + // Only the full-screen references view (<=576) hides MobileNav; the tablet-width + // drawer keeps the top bar title, and its z-[120] already sits above MobileNav z-[60]. useEffect(() => { if (!isNarrowLayout || !isOpen || !isFullBleedMobile) { return; @@ -497,10 +502,13 @@ export default function CitationReferencesDrawer({ } const fileName = getCitationDocumentName(documentPreview.detail); - const fileUrl = toAbsolutePreviewUrl( - getCitationDocumentUrl(documentPreview.detail) || await resolveCitationDocumentUrl(documentPreview.detail), - ); + // The original upload, not the derived preview — a clip must download as the + // clip, not as the transcript that happens to render in the panel. + const fileUrl = toAbsolutePreviewUrl(await resolveCitationDownloadUrl(documentPreview.detail)); if (!fileUrl) { + // Nothing to download (the backend withholds file URLs from viewers who + // lack view_file) — say so rather than letting the click do nothing. + showToast({ message: localize('com_citation.no_download_url'), status: 'error' }); return; } @@ -518,7 +526,7 @@ export default function CitationReferencesDrawer({ documentPreview.detail, null, ) - : { name: '文档预览', extension: '' }; + : { name: localize('com_citation.document_preview'), extension: '' }; // The "centered reading card" layout (max-w-[464/480]) only makes sense for the // citation list. When previewing a document, both header and body fill the // panel so they line up flush — capping the header alone leaves the body @@ -542,7 +550,8 @@ export default function CitationReferencesDrawer({ ? cn( // Mobile keeps the divider; desktop drops it to match the workspace panel. 'border-b border-[#ECECEC] px-4', - // 竖直:侧栏/全屏均在顶栏内垂直居中;全屏保留安全区 + 顶 16px,并加底内边距平衡 + // Vertical: centered in the top bar for both side panel and full screen; + // full screen keeps the safe area + 16px top, balanced by bottom padding. isFullBleedMobile ? 'pb-3 pt-[calc(env(safe-area-inset-top,0px)+1rem)]' : 'py-3', @@ -567,7 +576,7 @@ export default function CitationReferencesDrawer({ ? 'size-8 rounded-md hover:bg-[#F2F3F5] hover:text-[#4E5969]' : 'h-7 w-7 rounded-lg text-[#8C8C8C] hover:bg-gray-100', )} - aria-label="关闭参考资料" + aria-label={localize('com_citation.close_references')} > @@ -576,7 +585,7 @@ export default function CitationReferencesDrawer({
- 暂无参考资料 + {localize('com_citation.no_references')}
)} @@ -617,7 +626,7 @@ export default function CitationReferencesDrawer({ setDocumentPreview(null); }} className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-[#8C8C8C] transition-colors hover:bg-gray-100" - aria-label="返回参考资料列表" + aria-label={localize('com_citation.back_to_references')} > @@ -642,7 +651,7 @@ export default function CitationReferencesDrawer({ desktopButtonSize, desktopDownloadButtonClass, )} - aria-label="下载文档" + aria-label={localize('com_citation.download_document')} > @@ -654,7 +663,7 @@ export default function CitationReferencesDrawer({ desktopButtonSize, desktopCloseButtonClass, )} - aria-label="关闭参考资料" + aria-label={localize('com_citation.close_references')} > @@ -673,7 +682,7 @@ export default function CitationReferencesDrawer({ <>
{panelContent}
@@ -717,7 +726,7 @@ export default function CitationReferencesDrawer({
- 参考资料 + {localize('com_msg_source_reference')}
@@ -730,7 +739,7 @@ export default function CitationReferencesDrawer({ isFullBleedMobile ? ( @@ -740,7 +749,7 @@ export default function CitationReferencesDrawer({ 'fixed inset-y-0 right-0 z-[130] flex min-h-0 w-[min(520px,calc(100vw-24px))] min-w-0 flex-col overflow-hidden bg-white shadow-[0_8px_24px_rgba(0,0,0,0.12)] animate-in slide-in-from-right duration-300', 'rounded-tl-lg', )} - aria-label="参考资料" + aria-label={localize('com_msg_source_reference')} onClick={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > diff --git a/src/frontend/client/src/components/Chat/Messages/Content/citationUtils.ts b/src/frontend/client/src/components/Chat/Messages/Content/citationUtils.ts index 136bd80db2..1bdd5fc004 100644 --- a/src/frontend/client/src/components/Chat/Messages/Content/citationUtils.ts +++ b/src/frontend/client/src/components/Chat/Messages/Content/citationUtils.ts @@ -299,9 +299,19 @@ export function getCitationDocumentUrl(detail?: ChatCitation | null) { return getCitationDocumentPreviewUrl(detail); } -const inflightFileShareCache: Record> = {}; +/** The two addresses a knowledge file has. + * + * `originalUrl` is the file the user uploaded; `previewUrl` is the renderable + * stand-in the backend derived from it — the transcript of a clip, the PDF a + * pptx was converted to, the parsed markdown of a web page. Downloads must use + * the original (otherwise you hand someone a transcript named `.mp4`), while + * most viewers want the stand-in. + */ +export type CitationDocumentUrls = { originalUrl: string; previewUrl: string }; -export async function resolveCitationDocumentUrl(detail?: ChatCitation | null) { +const inflightFileShareCache: Record> = {}; + +export async function resolveCitationDocumentUrls(detail?: ChatCitation | null): Promise { const fileId = getCitationKnowledgeFileId(detail); if (fileId != null) { const cacheKey = String(fileId); @@ -310,9 +320,12 @@ export async function resolveCitationDocumentUrl(detail?: ChatCitation | null) { try { const res: any = await getFilePathApi(cacheKey); const data = res?.data ?? res; - return data?.preview_url || data?.original_url || ''; + return { + originalUrl: data?.original_url || '', + previewUrl: data?.preview_url || '', + }; } catch { - return ''; + return { originalUrl: '', previewUrl: '' }; } finally { // Drop after settle so a later open re-fetches a fresh signed URL // (signed URLs expire and we don't want to pin a dead one). @@ -320,11 +333,34 @@ export async function resolveCitationDocumentUrl(detail?: ChatCitation | null) { } })(); } - const url = await inflightFileShareCache[cacheKey]; - if (url) return url; + const urls = await inflightFileShareCache[cacheKey]; + if (urls.originalUrl || urls.previewUrl) return urls; } // Legacy fallback for non-knowledge or older payloads without documentId. - return getCitationDocumentPreviewUrl(detail); + const legacyUrl = getCitationDocumentPreviewUrl(detail); + return { originalUrl: legacyUrl, previewUrl: legacyUrl }; +} + +export async function resolveCitationDocumentUrl(detail?: ChatCitation | null) { + const { previewUrl, originalUrl } = await resolveCitationDocumentUrls(detail); + return previewUrl || originalUrl; +} + +/** Original file, for downloads — never the derived preview. */ +export async function resolveCitationDownloadUrl(detail?: ChatCitation | null) { + const { originalUrl, previewUrl } = await resolveCitationDocumentUrls(detail); + return originalUrl || previewUrl; +} + +const MEDIA_CITATION_EXTENSIONS = new Set([ + 'mp3', 'wav', 'm4a', 'aac', 'flac', 'ogg', + 'mp4', 'mov', 'avi', 'mkv', 'webm', +]); + +/** Whether the cited file is a clip. Decided from the file name, not the URL: + * a media file's preview URL points at its transcript (`.md`). */ +export function isMediaCitation(detail?: ChatCitation | null) { + return MEDIA_CITATION_EXTENSIONS.has(getCitationDocumentFileType(detail)); } export function toAbsolutePreviewUrl(url?: string | null) { diff --git a/src/frontend/client/src/components/Chat/resolveTaskMode.test.ts b/src/frontend/client/src/components/Chat/resolveTaskMode.test.ts new file mode 100644 index 0000000000..8d4c3a555c --- /dev/null +++ b/src/frontend/client/src/components/Chat/resolveTaskMode.test.ts @@ -0,0 +1,87 @@ +import { resolveTaskModeOnNavigation, type TaskModeNavigationInput } from './resolveTaskMode'; + +const base: TaskModeNavigationInput = { + conversationId: 'c1', + isTaskConversation: false, + canUseTaskMode: true, + navTaskMode: undefined, + isSelfRewrite: false, + userToggled: false, +}; + +const resolve = (overrides: Partial = {}) => + resolveTaskModeOnNavigation({ ...base, ...overrides }); + +describe('resolveTaskModeOnNavigation', () => { + // The production bug: a task conversation whose toggle was cleared by an + // in-conversation navigation (sidebar 首页, whose target IS the current + // pathname) never came back, because the reset ran on every location.key + // change while the restore's deps were constant within the visit. The user + // saw a lit button the whole time (`taskMode || taskRunning`) and their next + // turn silently went to the daily chain. + it('keeps a task conversation in task mode across repeated navigations', () => { + expect(resolve({ isTaskConversation: true })).toBe(true); + // Same inputs again — a second navigation must not flip it off. + expect(resolve({ isTaskConversation: true })).toBe(true); + }); + + it('keeps a daily conversation on daily', () => { + expect(resolve({ isTaskConversation: false })).toBe(false); + }); + + // History loads asynchronously: isTaskConversation is false on the first + // pass and true once the rows land, so the toggle settles off then on. + it('settles off before history resolves, then on', () => { + expect(resolve({ isTaskConversation: false })).toBe(false); + expect(resolve({ isTaskConversation: true })).toBe(true); + }); + + it('never enters task mode when the user cannot use it', () => { + expect(resolve({ isTaskConversation: true, canUseTaskMode: false })).toBe(false); + }); + + describe('manual toggle', () => { + it('is respected within the conversation it was made in', () => { + // User turned task mode OFF in a task conversation; navigating must + // not "restore" it against their wishes. + expect(resolve({ isTaskConversation: true, userToggled: true })).toBeNull(); + }); + + it('is respected in a daily conversation too', () => { + expect(resolve({ isTaskConversation: false, userToggled: true })).toBeNull(); + }); + }); + + describe('post-submit self-rewrite (/c/new -> /c/)', () => { + it('leaves the composing mode alone', () => { + // Same conversation, new URL: the history has not loaded yet, so + // deriving here would knock the user out of the mode they just + // submitted in. + expect(resolve({ isSelfRewrite: true, isTaskConversation: false })).toBeNull(); + }); + }); + + describe('/c/new', () => { + it('honours a navigation that declares task mode', () => { + expect(resolve({ conversationId: 'new', navTaskMode: true })).toBe(true); + }); + + it('honours a navigation that declares daily', () => { + expect(resolve({ conversationId: 'new', navTaskMode: false })).toBe(false); + }); + + // Regression: `newConversation` fires its own state-less + // navigate('/c/new') a tick after the sidebar button's. Reading the + // absent state as "daily" made 新建任务 open a daily chat until clicked + // a second time. + it('leaves the toggle alone when the navigation declares nothing', () => { + expect(resolve({ conversationId: 'new', navTaskMode: undefined })).toBeNull(); + }); + + it('ignores the loaded-history signal entirely', () => { + expect( + resolve({ conversationId: 'new', isTaskConversation: true, navTaskMode: undefined }), + ).toBeNull(); + }); + }); +}); diff --git a/src/frontend/client/src/components/Chat/resolveTaskMode.ts b/src/frontend/client/src/components/Chat/resolveTaskMode.ts new file mode 100644 index 0000000000..924f4f616a --- /dev/null +++ b/src/frontend/client/src/components/Chat/resolveTaskMode.ts @@ -0,0 +1,59 @@ +/** + * Decides where the task-mode toggle should sit after a navigation. + * + * Extracted from ChatView so the rule is testable on its own: it used to live in + * two effects that disagreed. One reset the toggle to off on every + * `location.key` change; the other restored it, but its deps + * (`[conversationId, isTaskConversation, canUseTaskMode]`) are all constant + * within a visit, so it could never fire twice. A single in-conversation + * navigation — clicking the sidebar 首页 entry, whose target IS the current + * pathname — therefore parked a task conversation on "daily" permanently, and + * the next turn silently went to the daily chain. + */ +export interface TaskModeNavigationInput { + /** Route param; `'new'` before the conversation has an id. */ + conversationId: string; + /** The loaded history contains a task turn belonging to this conversation. */ + isTaskConversation: boolean; + /** Task mode is available to this user / tenant at all. */ + canUseTaskMode: boolean; + /** `location.state.taskMode`; undefined when the navigation declares nothing. */ + navTaskMode?: boolean; + /** The post-submit `/c/new` → `/c/` self-rewrite (same conversation). */ + isSelfRewrite: boolean; + /** The user flipped the toggle by hand inside this conversation. */ + userToggled: boolean; +} + +/** + * Returns the mode to apply, or `null` to leave the toggle untouched. + */ +export function resolveTaskModeOnNavigation({ + conversationId, + isTaskConversation, + canUseTaskMode, + navTaskMode, + isSelfRewrite, + userToggled, +}: TaskModeNavigationInput): boolean | null { + if (conversationId === 'new') { + // Both sidebar entries set the atom themselves before navigating, so only a + // navigation that actually declares a mode is honoured here. Reading an + // absent state as "daily" used to drop the user's choice: `newConversation` + // fires its own state-less `navigate('/c/new')` a tick after ours, and that + // second landing reset the toggle the button had just set. + return navTaskMode === undefined ? null : !!navTaskMode; + } + // Same conversation, new URL: keep whatever the user is composing in. + if (isSelfRewrite) { + return null; + } + // An explicit manual choice outranks the derived mode until the user leaves. + if (userToggled) { + return null; + } + // Derive from the conversation's own history. `isTaskConversation` is false + // until the history resolves, so this settles on off first and flips back on + // once the loaded rows prove otherwise. + return isTaskConversation && canUseTaskMode; +} diff --git a/src/frontend/client/src/components/Linsight/Input/ContextChips.tsx b/src/frontend/client/src/components/Linsight/Input/ContextChips.tsx index 027d37e137..cad5ea345c 100644 --- a/src/frontend/client/src/components/Linsight/Input/ContextChips.tsx +++ b/src/frontend/client/src/components/Linsight/Input/ContextChips.tsx @@ -4,8 +4,10 @@ * attached files — each removable via "x". Tools never produce chips. */ import { Loader2, Paperclip, Sparkles, X } from 'lucide-react'; +import { Outlined } from 'bisheng-icons'; import BookOpen from '~/components/ui/icon/BookOpen'; import BooksIcon from '~/components/ui/icon/Books'; +import { useLocalize } from '~/hooks'; import type { TaskModeKnowledgeItem, TaskModeSkill } from '~/store/linsight'; export interface ContextAttachmentFile { @@ -17,6 +19,8 @@ export interface ContextAttachmentFile { filename?: string; file_name?: string; parsing_status?: string; + /** Folder upload: path relative to the picked folder, e.g. `年报/2024/Q1.xlsx`. */ + relative_path?: string; } interface ContextChipsProps { @@ -33,6 +37,51 @@ interface ContextChipsProps { onRemoveFile: (file: any) => void; } +export interface AttachmentGroup { + key: string; + /** Root directory of a folder upload; undefined for a loose file. */ + folderName?: string; + files: T[]; + isUploading: boolean; +} + +/** + * Collapse each uploaded folder into a single group keyed by its ROOT directory. + * + * A folder upload is capped at 100 files; rendering one chip each would bury the + * textarea and make "remove this folder" a hundred clicks. Loose files keep + * their own chip so single-file behaviour is unchanged. + */ +export function groupAttachmentsByFolder(files: T[]): AttachmentGroup[] { + const groups: AttachmentGroup[] = []; + const byFolder = new Map>(); + + for (const file of files) { + const root = (file.relative_path || '').split('/')[0]; + // A relative_path with no separator is a loose file, not a folder. + const isInFolder = !!root && (file.relative_path || '').includes('/'); + if (!isInFolder) { + groups.push({ key: `att-${file.clientId}`, files: [file], isUploading: !!file.isUploading }); + continue; + } + const existing = byFolder.get(root); + if (existing) { + existing.files.push(file); + existing.isUploading = existing.isUploading || !!file.isUploading; + continue; + } + const group: AttachmentGroup = { + key: `folder-${root}`, + folderName: root, + files: [file], + isUploading: !!file.isUploading, + }; + byFolder.set(root, group); + groups.push(group); + } + return groups; +} + const Chip = ({ icon, label, @@ -70,6 +119,7 @@ export function ContextChips({ onRemoveKnowledge, onRemoveFile, }: ContextChipsProps) { + const localize = useLocalize(); const orderedFiles: ContextAttachmentFile[] = attachmentFiles ?? [ ...uploadingFiles.map((file) => ({ clientId: file.id, @@ -88,21 +138,35 @@ export function ContextChips({ skills.length === 0 && knowledge.length === 0 && orderedFiles.length === 0; if (isEmpty) return null; + // A 100-file folder must not become 100 chips. Collapse each uploaded folder + // to one chip named after its root directory; loose files stay individual. + const groups = groupAttachmentsByFolder(orderedFiles); + return (
- {orderedFiles.map((file) => ( + {groups.map((group) => ( + ) : group.folderName ? ( + ) : ( ) } - label={file.name} - onRemove={file.isUploading ? undefined : () => onRemoveFile(file)} + label={ + group.folderName + ? `${group.folderName} (${localize('com_folder_upload_file_count', { 0: group.files.length })})` + : group.files[0].name + } + onRemove={ + group.isUploading + ? undefined + : () => group.files.forEach((file) => onRemoveFile(file)) + } /> ))} {skills.map((skill) => ( diff --git a/src/frontend/client/src/components/Linsight/Input/PlusMenu.tsx b/src/frontend/client/src/components/Linsight/Input/PlusMenu.tsx index b3fcf74506..821c073ba2 100644 --- a/src/frontend/client/src/components/Linsight/Input/PlusMenu.tsx +++ b/src/frontend/client/src/components/Linsight/Input/PlusMenu.tsx @@ -23,6 +23,11 @@ interface PlusMenuProps { disabled?: boolean; /** Opens the hidden file picker (InputFiles imperative ref). */ onUploadFile: () => void; + /** + * Opens the hidden DIRECTORY picker. Task mode only — omit it and the entry + * is hidden, which is what daily mode wants (no workspace, no tree to keep). + */ + onUploadFolder?: () => void; taskModeActive: boolean; onToggleTaskMode: () => void; selectedSkills: TaskModeSkill[]; @@ -34,6 +39,7 @@ interface PlusMenuProps { export function PlusMenu({ disabled = false, onUploadFile, + onUploadFolder, taskModeActive, onToggleTaskMode, selectedSkills, @@ -76,6 +82,20 @@ export function PlusMenu({ + {/* Upload folder — task mode only; the whole directory tree is + rebuilt inside the task workspace. */} + {onUploadFolder && ( + onUploadFolder()} + className="flex cursor-pointer items-center gap-3 rounded-xl px-2 py-1.5 outline-none" + > + + + {localize('com_ui_upload_folder')} + + + )} + {/* Divider between upload and the mode entries (spec §1) */}
diff --git a/src/frontend/client/src/components/Linsight/Input/TaskModeChatInput.tsx b/src/frontend/client/src/components/Linsight/Input/TaskModeChatInput.tsx index eac006ef07..c8952e3f45 100644 --- a/src/frontend/client/src/components/Linsight/Input/TaskModeChatInput.tsx +++ b/src/frontend/client/src/components/Linsight/Input/TaskModeChatInput.tsx @@ -126,6 +126,10 @@ export function TaskModeChatInput({ conversationId = 'new' }: TaskModeChatInputP file_id: item.file_id, file_name: item.filename || item.file_name || item.name, parsing_status: item.parsing_status || 'completed', + // Folder upload: the backend rebuilds this tree under the task + // workspace's uploads/ prefix. Undefined for a loose file. + relative_path: item.relative_path, + size: item.size, })), question: trimmed, tools: submissionTools as any, diff --git a/src/frontend/client/src/components/Linsight/Input/TaskModeInput.tsx b/src/frontend/client/src/components/Linsight/Input/TaskModeInput.tsx index 40282a772b..eef2f582e2 100644 --- a/src/frontend/client/src/components/Linsight/Input/TaskModeInput.tsx +++ b/src/frontend/client/src/components/Linsight/Input/TaskModeInput.tsx @@ -121,6 +121,8 @@ export function TaskModeInput({ conversationId = 'new', disabled = false, onFoll // Drag & paste upload support const { isDragging, handlePaste } = useFileDropAndPaste({ + // Task mode: a dropped directory is expanded with its tree preserved. + allowFolders: true, enabled: !disabled, onFilesReceived: (files: FileList | File[]) => { inputFilesRef.current?.upload(files); @@ -234,6 +236,10 @@ export function TaskModeInput({ conversationId = 'new', disabled = false, onFoll file_id: item.file_id, file_name: item.filename || item.file_name || item.name, parsing_status: item.parsing_status || 'completed', + // Folder upload: the backend rebuilds this tree under the task + // workspace's uploads/ prefix. Undefined for a loose file. + relative_path: item.relative_path, + size: item.size, })), question: trimmed, tools: submissionTools as any, @@ -279,18 +285,25 @@ export function TaskModeInput({ conversationId = 'new', disabled = false, onFoll setSkills((prev) => prev.filter((s) => s.name !== skill.name)); const handleRemoveFile = (file: any) => { - inputFilesRef.current?.removeByName?.(file.name || file.filename); + // clientId, not name: a folder upload can carry the same file name in + // several subdirectories, and removing by name would take out all of them. + inputFilesRef.current?.removeByClientId?.(file.clientId); setContext((prev) => ({ ...prev, - files: prev.files.filter((i: any) => (i.file_id || i.name) !== (file.file_id || file.name)), + files: prev.files.filter((i: any) => String(i.clientId) !== String(file.clientId)), })); }; const hasText = !!text.trim(); + // This box only ever runs inside a task, so both task-mode extras apply. `.ofd` + // used to be excluded here alone, which made the same file pickable when + // starting a task and rejected when following up on it — the backend carries + // ofd originals into the workspace either way. const accept = buildChatAccept({ enableMedia: !!(envConfig as any)?.enable_media_upload, enableEtl4lm: !!(bsConfig as any)?.enable_etl4lm, - includeOfd: false, + includeOfd: true, + taskMode: true, }); const InputFilesAny = InputFiles as any; @@ -307,6 +320,7 @@ export function TaskModeInput({ conversationId = 'new', disabled = false, onFoll hideTrigger hideList uploadMode="linsight" + allowFolderUpload uploadSizeLimits={resolveUploadSizeLimits(envConfig as any)} size={(envConfig as any)?.uploaded_files_maximum_size || 50} onFilesStateChange={(currentFiles: any[] = []) => { @@ -320,6 +334,8 @@ export function TaskModeInput({ conversationId = 'new', disabled = false, onFoll filename: f.name, file_name: f.name, parsing_status: f.parsingStatus, + // Drives the folder chip grouping in ContextChips. + relative_path: f.relativePath, })), ); }} @@ -373,6 +389,7 @@ export function TaskModeInput({ conversationId = 'new', disabled = false, onFoll inputFilesRef.current?.openPicker?.()} + onUploadFolder={() => inputFilesRef.current?.openFolderPicker?.()} taskModeActive onToggleTaskMode={handleExitTaskMode} selectedSkills={skills} diff --git a/src/frontend/client/src/layouts/MainLayout.tsx b/src/frontend/client/src/layouts/MainLayout.tsx index 2896ad7e35..4868173a26 100644 --- a/src/frontend/client/src/layouts/MainLayout.tsx +++ b/src/frontend/client/src/layouts/MainLayout.tsx @@ -39,10 +39,26 @@ interface SidebarItemProps { } function SidebarItem({ icon, activeIcon, to, active, label, showLabel = false, onNavigate }: SidebarItemProps) { + const location = useLocation(); + // Re-navigating to the path we are already on is a no-op the user cannot see, + // but react-router still mints a fresh location.key and every effect keyed on + // it re-runs. The 首页 entry points at `lastSectionPaths.home`, which IS the + // current `/c/` while a chat is open — so an idle click used to churn chat + // state for nothing. Swallow it here, the way Convo does for the conversation + // list. + const handleClick = (event: React.MouseEvent) => { + // Still fires on a same-path click: collapsing the H5 drawer is what the + // user asked for, only the redundant navigation is dropped. + onNavigate?.(); + if (to === location.pathname) { + event.preventDefault(); + } + }; + return ( { if (!accepts || accepts === '*') return true; - const fileName = file.name.toLowerCase(); - const acceptArr = accepts.split(',').map(a => a.trim().toLowerCase()); - - // 检查后缀名 (例如 .pdf) 或 MIME type - return acceptArr.some(type => { - if (type.startsWith('.')) { - return fileName.endsWith(type); - } - return file.type.match(new RegExp(type.replace('*', '.*'))); - }); + // 后缀名匹配与「退出任务模式」的附件清理共用同一个匹配器 + if (isFileNameAccepted(file.name, accepts)) return true; + // MIME type (例如 image/*) + return accepts + .split(',') + .map(a => a.trim().toLowerCase()) + .some(type => !type.startsWith('.') && file.type.match(new RegExp(type.replace('*', '.*')))); }; // @accepts '.png,.jpg' // `hideTrigger` hides the built-in attachment icon; caller invokes // `openPicker()` via the imperative ref (e.g. from the "+" menu). -const InputFiles = forwardRef(({ v, showVoice, accepts, disabled = false, size, uploadSizeLimits, onChange, onFilesStateChange, uploadMode, hideTrigger = false, hideList = false }, ref) => { +const InputFiles = forwardRef(({ v, showVoice, accepts, disabled = false, size, uploadSizeLimits, onChange, onFilesStateChange, uploadMode, allowFolderUpload = false, hideTrigger = false, hideList = false }, ref) => { const t = useLocalize() const [files, setFiles] = useState([]); const filesRef = useRef([]); const remainingUploadsRef = useRef(0); + // Attachments removed while their upload was still running. An in-flight + // upload's progress/success callbacks close over a snapshot taken before the + // removal, so without this they write the file straight back into the list + // and the X button looks dead — the card stays, and stays even after the + // upload finishes. Consulted by every upload callback before it touches state. + const removedIdsRef = useRef>(new Set()); + // One controller per in-flight upload so removing an attachment also stops + // the transfer instead of letting a 40MB body finish into a discarded slot. + const uploadControllersRef = useRef>(new Map()); const { showToast } = useToastContext(); const fileInputRef = useRef(null); + // Second, directory-mode picker. `webkitdirectory` cannot be toggled on the + // same input — the browser reads it once at click time — so the folder entry + // gets its own hidden input. + const folderInputRef = useRef(null); const resolvedLimits: UploadSizeLimits | null = uploadSizeLimits ?? null; const defaultFileSizeLimit = (size ?? 50) * 1024 * 1024; const defaultParsingStatus = uploadMode === 'linsight' ? 'running' : 'completed'; @@ -114,6 +133,12 @@ const InputFiles = forwardRef(({ v, showVoice, accepts, disabled = false, size, } return uploadMode === 'linsight'; }; + // Folder upload is task-mode only — daily chat has no agent workspace to + // rebuild a directory tree in. It is NOT inferable from `uploadMode`: task + // mode inside a conversation still uploads through the shared ('workstation') + // endpoint and only becomes a task at submit time. The caller knows whether + // task mode is on; this component does not. + const supportsFolderUpload = !!allowFolderUpload; const getUploadedFileIds = () => filesRef.current .filter((f) => f.id && !f.isUploading && f.filePath) .map((f) => ({ @@ -124,6 +149,10 @@ const InputFiles = forwardRef(({ v, showVoice, accepts, disabled = false, size, name: f.name, filename: f.name, file_name: f.name, + // Folder upload: the tree the user picked. The backend rebuilds it under + // the task workspace's uploads/ prefix; absent for a loose file. + relative_path: f.relativePath && f.relativePath !== f.name ? f.relativePath : undefined, + size: f.size, parsing_status: f.parsingStatus || defaultParsingStatus, parsingState: f.parsingStatus && !['completed', 'failed'].includes(f.parsingStatus) @@ -142,19 +171,25 @@ const InputFiles = forwardRef(({ v, showVoice, accepts, disabled = false, size, const invalidTypeFiles = []; const duplicateFiles = []; - fileInputRef.current.value = '' + if (fileInputRef.current) fileInputRef.current.value = ''; + if (folderInputRef.current) folderInputRef.current.value = ''; // Block re-uploading a file already attached this round (filesRef stays in // sync with state) plus intra-batch dupes — the chat has no server-side // dedup. Scoped to the current turn since the list clears after send. - const seenNames = new Set(filesRef.current.map((f) => f.name)); + // + // Keyed by RELATIVE PATH, not by name: a folder upload routinely carries + // several `summary.pdf` in different subdirectories, and a name-keyed set + // silently dropped all but the first — data loss with no message. + const seenPaths = new Set(filesRef.current.map((f) => f.relativePath || f.name)); const existingMediaCount = filesRef.current.filter((f) => isMediaFileName(f.name)).length; let incomingMediaCount = 0; // Validate files based on file extensions selectedFiles.forEach((file) => { + const relativePath = getFileRelativePath(file); if (!checkFileType(file, accepts)) { invalidTypeFiles.push(file); return; - } else if (seenNames.has(file.name)) { + } else if (seenPaths.has(relativePath)) { duplicateFiles.push(file); return; } @@ -165,8 +200,8 @@ const InputFiles = forwardRef(({ v, showVoice, accepts, disabled = false, size, incomingMediaCount += 1; } if (file.size <= maxBytes) { - seenNames.add(file.name); - validFiles.push({ id: generateUUID(6), file }); + seenPaths.add(relativePath); + validFiles.push({ id: generateUUID(6), file, relativePath }); } else { invalidFiles.push({ id: generateUUID(6), file }); } @@ -202,11 +237,12 @@ const InputFiles = forwardRef(({ v, showVoice, accepts, disabled = false, size, onChange(null); // Add valid files to state with initial progress - const filesWithProgress = validFiles.map(({ file, id }) => { + const filesWithProgress = validFiles.map(({ file, id, relativePath }) => { const isMedia = isMediaFileName(file.name); const isVideo = getMediaKind(file.name) === 'video'; return { name: file.name, + relativePath, size: file.size, type: file.type, isUploading: true, @@ -220,12 +256,14 @@ const InputFiles = forwardRef(({ v, showVoice, accepts, disabled = false, size, }; }); - setFiles(prevFiles => { - const res = [...prevFiles, ...filesWithProgress]; - filesRef.current = res; - onFilesStateChange?.(res); - return res; - }); + // filesRef mirrors the committed list, so the next list is derived from it + // rather than inside a setFiles updater: an updater runs during render, and + // notifying the parent from there updates it mid-render (React drops that + // update — see the note below). + const nextFiles = [...filesRef.current, ...filesWithProgress]; + filesRef.current = nextFiles; + setFiles(nextFiles); + onFilesStateChange?.(nextFiles); // Duration comes from the local file, so it is read as soon as the file // is picked rather than after the upload returns: it is what the hover @@ -259,6 +297,8 @@ const InputFiles = forwardRef(({ v, showVoice, accepts, disabled = false, size, const uploadPayload = createUploadPayload(file); logUploadStage(file.name, 'queue', uploadStartedAt, { size: file.size, type: file.type }); let lastLoggedProgress = -1; + const controller = new AbortController(); + uploadControllersRef.current.set(id, controller); return uploadChatFile(v, uploadPayload, (progress) => { if (progress >= 100 && lastLoggedProgress < 100) { logUploadStage(file.name, 'xhr_upload_complete', uploadStartedAt, { progress }); @@ -267,19 +307,27 @@ const InputFiles = forwardRef(({ v, showVoice, accepts, disabled = false, size, logUploadStage(file.name, 'xhr_progress', uploadStartedAt, { progress }); lastLoggedProgress = progress; } - // Update progress for each file individually - setFiles((prevFiles) => { - const updatedFiles = prevFiles.map(f => { - if (f.id === id) { - return { ...f, progress }; // Update progress for the specific file - } - return f; - }); - filesRef.current = updatedFiles; - onFilesStateChange?.(updatedFiles); - return updatedFiles; - }); - }, uploadMode, file.name).then(response => { + // The user removed this attachment while it was uploading; writing + // progress back would resurrect the card they just dismissed. + if (removedIdsRef.current.has(id)) { + return; + } + // Update progress for each file individually. Derived from filesRef + // (not a setFiles updater) so the parent notification below happens + // outside render — see the note in the selection handler. + const updatedFiles = filesRef.current.map(f => ( + f.id === id ? { ...f, progress } : f + )); + filesRef.current = updatedFiles; + setFiles(updatedFiles); + onFilesStateChange?.(updatedFiles); + }, uploadMode, file.name, controller.signal).then(response => { + if (removedIdsRef.current.has(id)) { + // Removed mid-flight: the upload landed, but the attachment is + // gone from the user's list and must not come back. + logUploadStage(file.name, 'discarded_after_remove', uploadStartedAt); + return; + } logUploadStage(file.name, 'api_response', uploadStartedAt, { status_code: response?.status_code, }); @@ -329,12 +377,23 @@ const InputFiles = forwardRef(({ v, showVoice, accepts, disabled = false, size, notifyUploadedFiles(getUploadedFileIds, onChange); logUploadStage(file.name, 'state_committed', uploadStartedAt); }).catch((e) => { + if (removedIdsRef.current.has(id)) { + // The abort below is the user's own removal, not a failure: + // the card is already gone and the counter already decremented, + // so an error toast here would be a lie and a second + // handleFileRemove would decrement twice. + logUploadStage(file.name, 'aborted_by_remove', uploadStartedAt); + return; + } logUploadStage(file.name, 'failed', uploadStartedAt, { error: String(e) }); console.log('e :>> ', e); showToast({ message: t('com_inputfiles_upload_failed', { 0: file.name }), status: 'error' }) - handleFileRemove(file.name); + handleFileRemove(id); remainingUploadsRef.current -= 1; // Decrease the remaining uploads count notifyUploadedFiles(getUploadedFileIds, onChange); + }).finally(() => { + uploadControllersRef.current.delete(id); + removedIdsRef.current.delete(id); }); }; @@ -352,45 +411,97 @@ const InputFiles = forwardRef(({ v, showVoice, accepts, disabled = false, size, }); }; + /** + * Folder pick / drop. + * + * Filters BEFORE gating, deliberately: every macOS folder carries `.DS_Store` + * and a real one carries files the platform cannot parse. Counting those + * toward the 100-file cap would reject folders that were never going to + * upload them — the cap exists to bound what actually reaches the workspace. + * What survives goes through the normal upload path, where per-file size, + * dedupe, upload and chips already work off `relativePath`. + */ + const handleFolderChange = (selectedFiles: File[]) => { + if (folderInputRef.current) folderInputRef.current.value = ''; + if (!selectedFiles.length) return; + + // Hidden files and anything inside a hidden directory (.git/, .venv/…) are + // a silent drop: nobody means to attach them, so saying so is just noise. + const visible = selectedFiles.filter((file) => !isHiddenPath(getFileRelativePath(file))); + const supported = visible.filter((file) => checkFileType(file, accepts)); + const skipped = visible.length - supported.length; + + if (!supported.length) { + showToast({ message: t('com_ui_upload_file_type_error'), status: 'error' }); + return; + } + if (skipped > 0) { + showToast({ message: t('com_folder_upload_skipped_unsupported', { 0: skipped }), status: 'info' }); + } + + const { rejection } = checkFolderBatch(supported); + if (rejection) { + const message = rejection === 'count' + ? t('com_folder_upload_too_many', { 0: TASK_MODE_MAX_FOLDER_FILES }) + : rejection === 'size' + ? t('com_folder_upload_too_large') + : t('com_folder_upload_too_deep', { 0: TASK_MODE_MAX_FOLDER_DEPTH }); + showToast({ message, status: 'error' }); + return; + } + handleFileChange(supported); + }; + useImperativeHandle(ref, () => ({ upload: (fileList) => { if (disabled) return; - handleFileChange(Array.from(fileList)); + const files = Array.from(fileList) as File[]; + // A drop can carry a whole directory; route it through the gate. + if (supportsFolderUpload && files.some((f) => getFileRelativePath(f) !== f.name)) { + handleFolderChange(files); + return; + } + handleFileChange(files); }, - removeByName: (fileName) => { - handleFileRemove(fileName); + removeByClientId: (clientId) => { + handleFileRemove(clientId); }, updateParsingStatus: (statusMap) => { - setFiles((prevFiles) => { - const updatedFiles = prevFiles.reduce((result, file) => { - const fileId = file.fileId || file.file_id; - const entry = normalizeParseStatusEntry(statusMap?.get?.(fileId)); - - if (!entry) { - result.push(file); - return result; - } + // Same rule as everywhere else in this file: derive from filesRef and + // notify the parent AFTER setFiles, never from inside an updater. + const updatedFiles = filesRef.current.reduce((result, file) => { + const fileId = file.fileId || file.file_id; + const entry = normalizeParseStatusEntry(statusMap?.get?.(fileId)); - if (entry.parsing_status === 'failed') { - return result; - } + if (!entry) { + result.push(file); + return result; + } - const nextFile = applyParseStatusToFile(file, entry); - if (nextFile) { - result.push(nextFile); - } + if (entry.parsing_status === 'failed') { return result; - }, []); + } - filesRef.current = updatedFiles; - onFilesStateChange?.(updatedFiles); - return updatedFiles; - }); + const nextFile = applyParseStatusToFile(file, entry); + if (nextFile) { + result.push(nextFile); + } + return result; + }, []); + + filesRef.current = updatedFiles; + setFiles(updatedFiles); + onFilesStateChange?.(updatedFiles); }, openPicker: () => { if (disabled) return; fileInputRef.current?.click(); }, + openFolderPicker: () => { + if (disabled || !supportsFolderUpload) return; + folderInputRef.current?.click(); + }, + supportsFolderUpload, clear: () => { filesRef.current.forEach(f => { if (f.previewUrl) URL.revokeObjectURL(f.previewUrl); @@ -417,36 +528,38 @@ const InputFiles = forwardRef(({ v, showVoice, accepts, disabled = false, size, const mergeParseStatusUpdates = useCallback((updates: Map) => { if (!updates.size) return; - setFiles((prevFiles) => { - let changed = false; - const nextFiles = prevFiles.reduce((result, file) => { - const fileId = file.fileId || file.file_id; - const entry = updates.get(fileId); - if (!entry) { - result.push(file); - return result; - } - if (entry.parsing_status === 'failed') { - changed = true; - return result; - } - const nextFile = applyParseStatusToFile(file, entry); - if (!nextFile) { - return result; - } - changed = changed - || nextFile.parsingStatus !== file.parsingStatus - || nextFile.cover_filepath !== file.cover_filepath; - result.push(nextFile); + // Derived from filesRef, not inside a setFiles updater: updaters run during + // render, and the parent notifications below would then update AiChatInput + // mid-render — React warns ("Cannot update a component while rendering a + // different component") and drops the update. + let changed = false; + const nextFiles = filesRef.current.reduce((result, file) => { + const fileId = file.fileId || file.file_id; + const entry = updates.get(fileId); + if (!entry) { + result.push(file); return result; - }, []); - - if (!changed) return prevFiles; - filesRef.current = nextFiles; - onFilesStateChange?.(nextFiles); - notifyUploadedFiles(getUploadedFileIds, onChange); - return nextFiles; - }); + } + if (entry.parsing_status === 'failed') { + changed = true; + return result; + } + const nextFile = applyParseStatusToFile(file, entry); + if (!nextFile) { + return result; + } + changed = changed + || nextFile.parsingStatus !== file.parsingStatus + || nextFile.cover_filepath !== file.cover_filepath; + result.push(nextFile); + return result; + }, []); + + if (!changed) return; + filesRef.current = nextFiles; + setFiles(nextFiles); + onFilesStateChange?.(nextFiles); + notifyUploadedFiles(getUploadedFileIds, onChange); }, [onChange, onFilesStateChange]); // Poll linsight upload parse status (ASR, etc.) until completed. @@ -480,12 +593,22 @@ const InputFiles = forwardRef(({ v, showVoice, accepts, disabled = false, size, return () => window.clearInterval(intervalId); }, [files, mergeParseStatusUpdates, uploadMode]); - const handleFileRemove = (fileName) => { - const removed = filesRef.current.find(file => file.name === fileName); + // Keyed by the per-attachment client id, not by name. Two files from + // different folders can share a name, and removing "the one called + // summary.pdf" would then take both out. + const handleFileRemove = (clientId) => { + const removed = filesRef.current.find(file => String(file.id) === String(clientId)); + // Claim the id BEFORE touching state: an upload still in flight has + // callbacks queued that would otherwise write this attachment straight + // back in, which is what made the X button look dead mid-upload. + if (removed?.isUploading) { + removedIdsRef.current.add(String(clientId)); + uploadControllersRef.current.get(String(clientId))?.abort(); + } if (removed?.previewUrl) URL.revokeObjectURL(removed.previewUrl); if (removed?.mediaPreviewUrl) URL.revokeObjectURL(removed.mediaPreviewUrl); if (removed?.mediaCoverUrl?.startsWith('blob:')) URL.revokeObjectURL(removed.mediaCoverUrl); - const res = filesRef.current.filter(file => file.name !== fileName); + const res = filesRef.current.filter(file => String(file.id) !== String(clientId)); filesRef.current = res setFiles(res); onFilesStateChange?.(res); @@ -518,7 +641,7 @@ const InputFiles = forwardRef(({ v, showVoice, accepts, disabled = false, size, mediaDurationSec: file.mediaDurationSec, parsingState: isParsing ? 'parsing' : undefined, }} - onRemove={() => handleFileRemove(file.name)} + onRemove={() => handleFileRemove(file.id)} variant="bar" /> ); @@ -531,7 +654,7 @@ const InputFiles = forwardRef(({ v, showVoice, accepts, disabled = false, size, previewUrl={/\.(png|jpe?g|bmp|gif|webp)$/i.test(file.name) ? file.previewUrl : undefined} variant="bar" isUploading={file.isUploading || isParsing} - onRemove={() => handleFileRemove(file.name)} + onRemove={() => handleFileRemove(file.id)} /> ); }; @@ -568,6 +691,21 @@ const InputFiles = forwardRef(({ v, showVoice, accepts, disabled = false, size, onChange={(e) => handleFileChange(Array.from(e.target.files))} className="hidden" /> + + {/* Directory Input — task mode only; opened via openFolderPicker(). + `accept` is deliberately omitted: a folder legitimately contains + unsupported files, and they are filtered per-file downstream + instead of making the picker itself look broken. */} + {supportsFolderUpload && ( + handleFolderChange(Array.from(e.target.files))} + className="hidden" + /> + )}
); }); diff --git a/src/frontend/client/src/pages/appChat/useFileDropAndPaste.ts b/src/frontend/client/src/pages/appChat/useFileDropAndPaste.ts index 20d5048bd5..323f2b7733 100644 --- a/src/frontend/client/src/pages/appChat/useFileDropAndPaste.ts +++ b/src/frontend/client/src/pages/appChat/useFileDropAndPaste.ts @@ -1,6 +1,7 @@ // @ts-strict-ignore import { useState, useRef, useEffect, useCallback } from 'react'; import { generateUUID } from '~/utils'; +import { extractDroppedDirectories, readFolderFilesRecursive } from '~/utils/folderUpload'; // Clipboard screenshots always arrive as an image File named "image.png" (or // with an empty name). InputFiles dedups by file name, so pasting a second @@ -21,7 +22,12 @@ const uniquifyPastedFile = (file: File): File => { } }; -export const useFileDropAndPaste = ({ enabled, onFilesReceived }) => { +/** + * @param allowFolders Accept dropped DIRECTORIES, expanded recursively with the + * folder tree preserved on each File's `webkitRelativePath`. Task mode only: + * daily chat has no workspace to rebuild a tree in. + */ +export const useFileDropAndPaste = ({ enabled, onFilesReceived, allowFolders = false }) => { const [isDragging, setIsDragging] = useState(false); const dragCounter = useRef(0); @@ -59,6 +65,19 @@ export const useFileDropAndPaste = ({ enabled, onFilesReceived }) => { setIsDragging(false); dragCounter.current = 0; + // Directories must be pulled off the DataTransferItemList + // synchronously — it is invalidated the moment this handler returns. + // `dataTransfer.files` does not surface a dropped folder's contents at + // all, so without this a dropped folder silently did nothing. + const dirEntries = allowFolders ? extractDroppedDirectories(e.dataTransfer) : []; + if (dirEntries.length > 0) { + void Promise.all(dirEntries.map((dir) => readFolderFilesRecursive(dir, ''))).then((groups) => { + const files = groups.flat(); + if (files.length > 0) onFilesReceived(files); + }); + return; + } + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { onFilesReceived(e.dataTransfer.files); e.dataTransfer.clearData(); @@ -76,7 +95,7 @@ export const useFileDropAndPaste = ({ enabled, onFilesReceived }) => { window.removeEventListener('dragover', handleDragOver); window.removeEventListener('drop', handleDrop); }; - }, [enabled, onFilesReceived]); + }, [enabled, onFilesReceived, allowFolders]); // 2. pasete const handlePaste = useCallback((e) => { diff --git a/src/frontend/client/src/pages/knowledge/FilePreview/RichKnowledgePreview.tsx b/src/frontend/client/src/pages/knowledge/FilePreview/RichKnowledgePreview.tsx index e307936c8d..06d0214471 100644 --- a/src/frontend/client/src/pages/knowledge/FilePreview/RichKnowledgePreview.tsx +++ b/src/frontend/client/src/pages/knowledge/FilePreview/RichKnowledgePreview.tsx @@ -205,7 +205,9 @@ function MarkdownFromUrl({ fileUrl }: { fileUrl: string }) { return ; } -function MediaTranscriptTabs({ fileUrl }: { fileUrl: string }) { +/** Transcript pane of a media preview: 识别文本 / 入库文本 tabs over the parsed + * markdown. Exported so citation previews render the same pane. */ +export function MediaTranscriptTabs({ fileUrl }: { fileUrl: string }) { const localize = useLocalize(); const [activeTab, setActiveTab] = useState("recognized"); const [content, setContent] = useState(""); diff --git a/src/frontend/client/src/pages/knowledge/FilePreview/index.tsx b/src/frontend/client/src/pages/knowledge/FilePreview/index.tsx index 71ae4abc1d..e70dcf9442 100644 --- a/src/frontend/client/src/pages/knowledge/FilePreview/index.tsx +++ b/src/frontend/client/src/pages/knowledge/FilePreview/index.tsx @@ -9,6 +9,9 @@ import { useCallback, useEffect, useState } from "react"; import { Sidebar } from "./Sidebar"; import { TopBar } from "./TopBar"; import { getViewerType, supportsPagination, supportsSidebar, supportsZoom } from "./viewers"; +import { MediaPlayer } from "./MediaPlayer"; +import { MediaTranscriptTabs } from "./RichKnowledgePreview"; +import { cn } from "~/utils"; import { DocxViewer } from "./viewers/DocxViewer"; import { HtmlViewer } from "./viewers/HtmlViewer"; import { ImageViewer } from "./viewers/ImageViewer"; @@ -51,6 +54,10 @@ export interface FilePreviewProps { hideHeaderDownload?: boolean; /** Optional business-level download handler. Defaults to downloading fileUrl. */ onDownloadFile?: () => void; + /** Parsed-transcript URL for an audio/video file. When set, the media viewer + * shows the 识别文本 / 入库文本 pane next to the player, like the knowledge + * space does — the cited text lives in the transcript, not in the clip. */ + transcriptUrl?: string; } export default function FilePreview({ @@ -67,6 +74,7 @@ export default function FilePreview({ allowDownload = true, hideHeaderDownload = false, onDownloadFile, + transcriptUrl = "", }: FilePreviewProps) { const localize = useLocalize(); const viewerType = getViewerType(fileType); @@ -247,6 +255,31 @@ export default function FilePreview({ return ; case "text": return ; + case "audio": + case "video": + // Player on top, the transcript the answer actually quoted below + // it. Stacked rather than split: this preview lives in a narrow + // citation panel, where two columns leave both halves cramped. + return ( +
+
+ +
+ {transcriptUrl ? ( + <> +
+
+ +
+ + ) : null} +
+ ); default: return null; } diff --git a/src/frontend/client/src/pages/knowledge/FilePreview/viewers/index.ts b/src/frontend/client/src/pages/knowledge/FilePreview/viewers/index.ts index 4217aacad4..813f1203ed 100644 --- a/src/frontend/client/src/pages/knowledge/FilePreview/viewers/index.ts +++ b/src/frontend/client/src/pages/knowledge/FilePreview/viewers/index.ts @@ -1,6 +1,6 @@ /** Maps file extension → viewer type for the FilePreview dispatcher. */ -export type ViewerType = "pdf" | "docx" | "xlsx" | "markdown" | "html" | "image" | "text" | "unsupported"; +export type ViewerType = "pdf" | "docx" | "xlsx" | "markdown" | "html" | "image" | "text" | "audio" | "video" | "unsupported"; const EXT_MAP: Record = { // PDF @@ -30,6 +30,19 @@ const EXT_MAP: Record = { webp: "image", // Text txt: "text", + // Audio + mp3: "audio", + wav: "audio", + m4a: "audio", + aac: "audio", + flac: "audio", + ogg: "audio", + // Video + mp4: "video", + mov: "video", + avi: "video", + mkv: "video", + webm: "video", }; export function getViewerType(fileType: string): ViewerType { @@ -48,5 +61,11 @@ export function supportsPagination(type: ViewerType): boolean { /** Formats that support zoom */ export function supportsZoom(type: ViewerType): boolean { - return type !== "unsupported"; + // A player sizes itself to its own control bar — zooming it means nothing. + return type !== "unsupported" && type !== "audio" && type !== "video"; +} + +/** Audio/video, rendered by the media player rather than a document viewer. */ +export function isMediaViewer(type: ViewerType): boolean { + return type === "audio" || type === "video"; } diff --git a/src/frontend/client/src/pages/knowledge/hooks/useFileDragDrop.ts b/src/frontend/client/src/pages/knowledge/hooks/useFileDragDrop.ts index 3310bf5783..3b2fb5e232 100644 --- a/src/frontend/client/src/pages/knowledge/hooks/useFileDragDrop.ts +++ b/src/frontend/client/src/pages/knowledge/hooks/useFileDragDrop.ts @@ -8,6 +8,7 @@ import { getMaxFileSizeMBForFile, type UploadSizeLimits, } from "../knowledgeUtils"; +import { extractDroppedDirectories, readFolderFilesRecursive } from "~/utils/folderUpload"; import { useLocalize } from "~/hooks"; // Only react to OS file-upload drags. Internal drags (e.g. F034 move, which @@ -34,67 +35,6 @@ interface UseFileDragDropOptions { enableEtl4lm?: boolean; } -/** - * Recursively read every file under a dropped directory (F034 §5.5: nested - * upload — the backend rebuilds the whole tree). Each returned File gets a - * synthetic `webkitRelativePath` of `"//"` so it flows through - * the folder-upload pipeline exactly like the `webkitdirectory` picker. - * `readEntries` returns in batches, so it must be called repeatedly until it - * yields an empty list. - */ -function readFolderFilesRecursive( - dirEntry: FileSystemDirectoryEntry, - pathPrefix: string, -): Promise { - const prefix = pathPrefix ? `${pathPrefix}/${dirEntry.name}` : dirEntry.name; - return new Promise((resolve) => { - const reader = dirEntry.createReader(); - const collected: Promise[] = []; - const finish = () => - Promise.all(collected).then((parts) => - resolve(parts.flat().filter((f): f is File => f != null)), - ); - const readBatch = () => { - reader.readEntries((batch) => { - if (batch.length === 0) { - finish(); - return; - } - for (const ent of batch) { - if (ent.isFile) { - const fileEntry = ent as FileSystemFileEntry; - collected.push( - new Promise((res) => { - fileEntry.file( - (f) => { - try { - Object.defineProperty(f, "webkitRelativePath", { - value: `${prefix}/${f.name}`, - configurable: true, - }); - } catch { - // Property locked on this engine; the folder filter - // then falls back to file.name and drops it — safe. - } - res(f); - }, - () => res(null), - ); - }), - ); - } else if (ent.isDirectory) { - collected.push( - readFolderFilesRecursive(ent as FileSystemDirectoryEntry, prefix), - ); - } - } - readBatch(); - }, finish); - }; - readBatch(); - }); -} - /** * Manages drag-and-drop file upload interactions. * Extracted from SpaceDetail/index.tsx. @@ -183,16 +123,8 @@ export function useFileDragDrop({ // entries must be read out synchronously here — the DataTransferItemList // is invalidated once this handler returns, though the FileSystemEntry // objects it yields stay valid for the async directory read. - const items = e.dataTransfer.items; - if (onUploadFolder && items && items.length > 0) { - let dirEntry: FileSystemDirectoryEntry | null = null; - for (let i = 0; i < items.length; i++) { - const entry = items[i].webkitGetAsEntry?.(); - if (entry?.isDirectory) { - dirEntry = entry as FileSystemDirectoryEntry; - break; - } - } + if (onUploadFolder) { + const dirEntry = extractDroppedDirectories(e.dataTransfer)[0] ?? null; if (dirEntry) { // handleUploadFolder owns count cap / hidden / dup-name / silent // filtering, so read the whole tree (nested, F034 §5.5) and hand diff --git a/src/frontend/client/src/utils/folderUpload.test.ts b/src/frontend/client/src/utils/folderUpload.test.ts new file mode 100644 index 0000000000..dffb92b636 --- /dev/null +++ b/src/frontend/client/src/utils/folderUpload.test.ts @@ -0,0 +1,146 @@ +import { + checkFolderBatch, + extractDroppedDirectories, + getFileRelativePath, + getFolderDepth, + readFolderFilesRecursive, + TASK_MODE_MAX_FOLDER_DEPTH, + TASK_MODE_MAX_FOLDER_FILES, + TASK_MODE_MAX_FOLDER_TOTAL_BYTES, +} from "./folderUpload"; + +/** A File with the relative path the `webkitdirectory` picker would stamp. */ +function makeFile(name: string, relativePath?: string, size = 1): File { + // `size` is stamped rather than materialized — the size cases run to + // hundreds of MB and jsdom would try to allocate every byte. + const file = new File(["x"], name); + if (relativePath) { + Object.defineProperty(file, "webkitRelativePath", { value: relativePath, configurable: true }); + } + Object.defineProperty(file, "size", { value: size, configurable: true }); + return file; +} + +/** + * Minimal Entries-API fakes. `readEntries` deliberately yields its children in + * two batches so the reader's "call until empty" loop is actually exercised — + * a single-batch fake would pass even with the loop removed. + */ +type FakeTree = { [name: string]: FakeTree | null }; + +function makeDirEntry(name: string, tree: FakeTree): FileSystemDirectoryEntry { + const children = Object.entries(tree).map(([childName, sub]) => + sub === null ? makeFileEntry(childName) : makeDirEntry(childName, sub), + ); + return { + name, + isFile: false, + isDirectory: true, + createReader: () => { + let cursor = 0; + return { + readEntries: (onOk: (batch: unknown[]) => void) => { + // one child per batch, then an empty batch to terminate + const batch = cursor < children.length ? [children[cursor]] : []; + cursor += 1; + onOk(batch); + }, + }; + }, + } as unknown as FileSystemDirectoryEntry; +} + +function makeFileEntry(name: string) { + return { + name, + isFile: true, + isDirectory: false, + file: (onOk: (f: File) => void) => onOk(new File(["x"], name)), + }; +} + +describe("getFileRelativePath / getFolderDepth", () => { + test("a picked folder file keeps its relative path", () => { + expect(getFileRelativePath(makeFile("Q1.xlsx", "Reports/2024/Q1.xlsx"))).toBe("Reports/2024/Q1.xlsx"); + }); + + test("a loose file falls back to its bare name", () => { + expect(getFileRelativePath(makeFile("report.pdf"))).toBe("report.pdf"); + }); + + test("depth counts directories, not the file itself", () => { + expect(getFolderDepth("report.pdf")).toBe(0); + expect(getFolderDepth("docs/report.pdf")).toBe(1); + expect(getFolderDepth("Reports/2024/Q1.xlsx")).toBe(2); + }); +}); + +describe("checkFolderBatch", () => { + test("accepts a batch inside every limit", () => { + const result = checkFolderBatch([makeFile("a.pdf", "docs/a.pdf"), makeFile("b.pdf", "docs/b.pdf")]); + expect(result.rejection).toBeUndefined(); + expect(result.fileCount).toBe(2); + expect(result.maxDepth).toBe(1); + }); + + test("rejects on file count", () => { + const files = Array.from({ length: TASK_MODE_MAX_FOLDER_FILES + 1 }, (_, i) => + makeFile(`${i}.pdf`, `docs/${i}.pdf`), + ); + expect(checkFolderBatch(files).rejection).toBe("count"); + }); + + test("rejects on total size", () => { + const half = Math.floor(TASK_MODE_MAX_FOLDER_TOTAL_BYTES / 2) + 1; + const files = [makeFile("a.pdf", "docs/a.pdf", half), makeFile("b.pdf", "docs/b.pdf", half)]; + expect(checkFolderBatch(files).rejection).toBe("size"); + }); + + test("rejects on nesting depth", () => { + const deep = Array.from({ length: TASK_MODE_MAX_FOLDER_DEPTH + 1 }, (_, i) => `d${i}`).join("/"); + expect(checkFolderBatch([makeFile("f.pdf", `${deep}/f.pdf`)]).rejection).toBe("depth"); + }); + + test("exactly at the depth limit is accepted", () => { + const deep = Array.from({ length: TASK_MODE_MAX_FOLDER_DEPTH }, (_, i) => `d${i}`).join("/"); + expect(checkFolderBatch([makeFile("f.pdf", `${deep}/f.pdf`)]).rejection).toBeUndefined(); + }); +}); + +describe("readFolderFilesRecursive", () => { + test("flattens a nested tree and stamps each file's relative path", async () => { + const dir = makeDirEntry("Reports", { + "overview.md": null, + "2024": { "Q1.xlsx": null, "Q2.xlsx": null }, + }); + + const files = await readFolderFilesRecursive(dir, ""); + const paths = files.map(getFileRelativePath).sort(); + + expect(paths).toEqual(["Reports/2024/Q1.xlsx", "Reports/2024/Q2.xlsx", "Reports/overview.md"]); + }); + + test("an empty directory resolves to no files rather than hanging", async () => { + expect(await readFolderFilesRecursive(makeDirEntry("empty", {}), "")).toEqual([]); + }); +}); + +describe("extractDroppedDirectories", () => { + const asItem = (entry: unknown) => ({ webkitGetAsEntry: () => entry }); + + test("returns only the directory entries", () => { + const dir = makeDirEntry("docs", {}); + const items = [asItem(makeFileEntry("loose.pdf")), asItem(dir)]; + const dataTransfer = { items: Object.assign(items, { length: items.length }) } as unknown as DataTransfer; + + expect(extractDroppedDirectories(dataTransfer)).toEqual([dir]); + }); + + test("is safe on engines without the Entries API", () => { + const items = [{}]; + const dataTransfer = { items: Object.assign(items, { length: 1 }) } as unknown as DataTransfer; + + expect(extractDroppedDirectories(dataTransfer)).toEqual([]); + expect(extractDroppedDirectories(null)).toEqual([]); + }); +}); diff --git a/src/frontend/client/src/utils/folderUpload.ts b/src/frontend/client/src/utils/folderUpload.ts new file mode 100644 index 0000000000..6024367dae --- /dev/null +++ b/src/frontend/client/src/utils/folderUpload.ts @@ -0,0 +1,146 @@ +/** + * Folder-upload plumbing shared by the knowledge space (F034) and the task-mode + * chat input. + * + * Both surfaces need the same two primitives: turn a *dropped* directory into a + * flat `File[]` whose entries still remember where they came from, and agree on + * how large a folder may be. The `webkitdirectory` picker already stamps + * `webkitRelativePath` on every File; the Entries API used for drag-and-drop does + * not, so the reader below synthesizes one. That way a dropped folder and a + * picked folder flow through the exact same downstream code. + */ + +/** Spread onto an `` to turn it into a directory picker. */ +export const FOLDER_INPUT_PROPS = { webkitdirectory: '', directory: '' } as Record; + +/** + * Task-mode folder caps. Mirrored server-side in `workbench_impl.py` + * (`_FOLDER_MAX_FILES` / `_FOLDER_MAX_TOTAL_BYTES` / `_FOLDER_MAX_DEPTH`) — these + * exist for instant feedback before a byte is uploaded, the server-side copy is + * the one that actually guarantees anything. + */ +export const TASK_MODE_MAX_FOLDER_FILES = 100; +export const TASK_MODE_MAX_FOLDER_TOTAL_BYTES = 500 * 1024 * 1024; +export const TASK_MODE_MAX_FOLDER_DEPTH = 10; + +/** The folder-relative path of a File, or its bare name for a loose file. */ +export function getFileRelativePath(file: File): string { + return (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; +} + +/** Number of DIRECTORY levels in a relative path; the file name is not a level. */ +export function getFolderDepth(relativePath: string): number { + return Math.max(relativePath.split('/').length - 1, 0); +} + +/** + * Recursively read every file under a dropped directory. Each returned File gets + * a synthetic `webkitRelativePath` of `"//"`. + * `readEntries` returns in batches, so it must be called repeatedly until it + * yields an empty list. + */ +export function readFolderFilesRecursive( + dirEntry: FileSystemDirectoryEntry, + pathPrefix: string, +): Promise { + const prefix = pathPrefix ? `${pathPrefix}/${dirEntry.name}` : dirEntry.name; + return new Promise((resolve) => { + const reader = dirEntry.createReader(); + const collected: Promise[] = []; + const finish = () => + Promise.all(collected).then((parts) => + resolve(parts.flat().filter((f): f is File => f != null)), + ); + const readBatch = () => { + reader.readEntries((batch) => { + if (batch.length === 0) { + finish(); + return; + } + for (const ent of batch) { + if (ent.isFile) { + const fileEntry = ent as FileSystemFileEntry; + collected.push( + new Promise((res) => { + fileEntry.file( + (f) => { + try { + Object.defineProperty(f, 'webkitRelativePath', { + value: `${prefix}/${f.name}`, + configurable: true, + }); + } catch { + // Property locked on this engine; the folder filter + // then falls back to file.name and drops it — safe. + } + res(f); + }, + () => res(null), + ); + }), + ); + } else if (ent.isDirectory) { + collected.push( + readFolderFilesRecursive(ent as FileSystemDirectoryEntry, prefix), + ); + } + } + readBatch(); + }, finish); + }; + readBatch(); + }); +} + +/** + * Pull the directory entries out of a drop event. + * + * MUST be called synchronously inside the drop handler: the `DataTransferItemList` + * is invalidated as soon as the handler returns, though the `FileSystemEntry` + * objects it yields stay valid for the async directory read that follows. + */ +export function extractDroppedDirectories(dataTransfer: DataTransfer | null): FileSystemDirectoryEntry[] { + const items = dataTransfer?.items; + if (!items || items.length === 0) return []; + const dirs: FileSystemDirectoryEntry[] = []; + for (let i = 0; i < items.length; i++) { + const entry = items[i].webkitGetAsEntry?.(); + if (entry?.isDirectory) { + dirs.push(entry as FileSystemDirectoryEntry); + } + } + return dirs; +} + +export type FolderBatchRejection = 'count' | 'size' | 'depth'; + +export interface FolderBatchCheck { + /** Set when the batch must be rejected as a whole. */ + rejection?: FolderBatchRejection; + fileCount: number; + totalBytes: number; + maxDepth: number; +} + +/** + * All-or-nothing gate on a picked/dropped folder. + * + * Truncating to the first N files would hand the user a workspace that looks + * complete and is not — the agent would then summarize a partial folder with + * nobody the wiser. Rejecting the batch makes the user pick a smaller folder, + * which is the only outcome they can actually reason about. + */ +export function checkFolderBatch(files: File[]): FolderBatchCheck { + const totalBytes = files.reduce((sum, f) => sum + (f.size || 0), 0); + const maxDepth = files.reduce((deepest, f) => Math.max(deepest, getFolderDepth(getFileRelativePath(f))), 0); + const result: FolderBatchCheck = { fileCount: files.length, totalBytes, maxDepth }; + + if (files.length > TASK_MODE_MAX_FOLDER_FILES) { + result.rejection = 'count'; + } else if (totalBytes > TASK_MODE_MAX_FOLDER_TOTAL_BYTES) { + result.rejection = 'size'; + } else if (maxDepth > TASK_MODE_MAX_FOLDER_DEPTH) { + result.rejection = 'depth'; + } + return result; +} diff --git a/src/frontend/packages/locales/src/api_errors/en.json b/src/frontend/packages/locales/src/api_errors/en.json index b00c433f4e..1f5da9c762 100644 --- a/src/frontend/packages/locales/src/api_errors/en.json +++ b/src/frontend/packages/locales/src/api_errors/en.json @@ -149,6 +149,10 @@ "11010": "Invalid SOP file format", "11011": "Failed to set featured SOP case", "11020": "File upload failed", + "11021": "A folder may contain at most 100 files. Please reduce it and upload again.", + "11022": "The folder exceeds the 500MB total size limit. Please reduce it and upload again.", + "11023": "Folder nesting exceeds the 10-level limit. Please flatten it and upload again.", + "11024": "The file exceeds the upload size limit. Please compress or split it and retry.", "11030": "Usage quota exceeded. Activate with a new invite code", "11040": "Failed to submit question", "11050": "Vector retrieval model error. Contact admin", diff --git a/src/frontend/packages/locales/src/api_errors/ja.json b/src/frontend/packages/locales/src/api_errors/ja.json index 8a5ed4443e..524b778882 100644 --- a/src/frontend/packages/locales/src/api_errors/ja.json +++ b/src/frontend/packages/locales/src/api_errors/ja.json @@ -149,6 +149,10 @@ "11010": "SOPファイル形式が不正です", "11011": "SOPの推薦事例設定に失敗しました", "11020": "ファイルアップロードに失敗しました", + "11021": "フォルダーに含められるファイルは最大 100 件です。減らして再アップロードしてください。", + "11022": "フォルダーの合計サイズが 500MB の上限を超えています。減らして再アップロードしてください。", + "11023": "フォルダーの階層が 10 階層の上限を超えています。構造を簡素化して再アップロードしてください。", + "11024": "ファイルサイズがアップロード上限を超えています。圧縮または分割して再試行してください。", "11030": "利用回数の上限に達しました。新しい招待コードが必要です", "11040": "ユーザー質問の送信に失敗しました", "11050": "ベクトル検索モデルの問題です。管理者に相談してください", diff --git a/src/frontend/packages/locales/src/api_errors/zh-Hans.json b/src/frontend/packages/locales/src/api_errors/zh-Hans.json index d7921d71b1..0d30eda8e2 100644 --- a/src/frontend/packages/locales/src/api_errors/zh-Hans.json +++ b/src/frontend/packages/locales/src/api_errors/zh-Hans.json @@ -149,6 +149,10 @@ "11010": "SOP文件格式不符合要求", "11011": "SOP设置精选案例失败", "11020": "文件上传失败", + "11021": "文件夹最多包含 100 个文件,请精简后重新上传", + "11022": "文件夹总大小超过 500MB 上限,请精简后重新上传", + "11023": "文件夹层级超过 10 层上限,请精简目录结构后重新上传", + "11024": "文件大小超过上传限制,请压缩或拆分后重试", "11030": "您的$t(linsight)使用次数已用完,请使用新的邀请码激活$t(linsight)功能", "11040": "提交$t(linsight)用户问题失败", "11050": "请联系管理员检查工作台向量检索模型状态", diff --git a/src/frontend/platform/eslint-suppressions.json b/src/frontend/platform/eslint-suppressions.json index 1535c9a935..30d04094a6 100644 --- a/src/frontend/platform/eslint-suppressions.json +++ b/src/frontend/platform/eslint-suppressions.json @@ -197,17 +197,11 @@ }, "@typescript-eslint/no-unused-vars": { "count": 1 - }, - "no-restricted-syntax": { - "count": 7 } }, "src/components/bs-comp/chatComponent/CitationReferencesDrawer.tsx": { "@typescript-eslint/no-explicit-any": { "count": 1 - }, - "no-restricted-syntax": { - "count": 16 } }, "src/components/bs-comp/chatComponent/CitationSourceIcon.tsx": { @@ -925,9 +919,6 @@ "no-console": { "count": 2 }, - "no-restricted-syntax": { - "count": 4 - }, "react-hooks/exhaustive-deps": { "count": 1 } @@ -2972,7 +2963,7 @@ "count": 2 }, "no-restricted-syntax": { - "count": 8 + "count": 2 } }, "src/pages/KnowledgePage/components/RuleFile.tsx": { diff --git a/src/frontend/platform/public/locales/en-US/api_errors.json b/src/frontend/platform/public/locales/en-US/api_errors.json index b00c433f4e..1f5da9c762 100644 --- a/src/frontend/platform/public/locales/en-US/api_errors.json +++ b/src/frontend/platform/public/locales/en-US/api_errors.json @@ -149,6 +149,10 @@ "11010": "Invalid SOP file format", "11011": "Failed to set featured SOP case", "11020": "File upload failed", + "11021": "A folder may contain at most 100 files. Please reduce it and upload again.", + "11022": "The folder exceeds the 500MB total size limit. Please reduce it and upload again.", + "11023": "Folder nesting exceeds the 10-level limit. Please flatten it and upload again.", + "11024": "The file exceeds the upload size limit. Please compress or split it and retry.", "11030": "Usage quota exceeded. Activate with a new invite code", "11040": "Failed to submit question", "11050": "Vector retrieval model error. Contact admin", diff --git a/src/frontend/platform/public/locales/en-US/bs.json b/src/frontend/platform/public/locales/en-US/bs.json index 062028f0db..01b53b4ccc 100644 --- a/src/frontend/platform/public/locales/en-US/bs.json +++ b/src/frontend/platform/public/locales/en-US/bs.json @@ -521,6 +521,8 @@ "fileUploadFailed": "File upload failed: {{name}}", "fileExceedRemoved": "File: {{name}} exceeds {{size}}MB and has been removed", "fileTypeNotAllowed": "Unsupported file type: {{type}}", + "mediaFileTooMany": "You cannot upload more than 5 audio/video files.", + "noFileSelected": "No file selected", "uploadFailedCheckFormat": "Upload failed, please check file format", "sourceTooltip": "Source Paragraph", "filterLabel": "Filter Labels", @@ -2081,5 +2083,30 @@ "githubRateLimit": "GitHub rate limit exceeded, please retry later", "unknown": "Operation failed, please retry" } + }, + "citation": { + "unsupportedPreview": "Preview is not supported for this file type", + "loadingPreview": "Loading document preview...", + "noPreviewUrl": "No previewable file address", + "documentPreview": "Document preview", + "downloadDocument": "Download document", + "closeDocumentPreview": "Close document preview", + "web": "Web", + "document": "Document", + "untitled": "Untitled", + "loadingDetail": "Loading source details...", + "loadDetailFailed": "Failed to load source details", + "policyDocument": "Policy document", + "references": "References", + "closeReferences": "Close references", + "backToReferences": "Back to reference list", + "noDownloadUrl": "No downloadable file address" + }, + "mediaPreview": { + "recognizedText": "Recognized text", + "entryText": "Stored text", + "webPreview": "Web preview", + "openSourcePage": "Open source page", + "noWebSnapshot": "No web snapshot yet — check the stored text or open the source page." } } diff --git a/src/frontend/platform/public/locales/en-US/permission.json b/src/frontend/platform/public/locales/en-US/permission.json index 3c9b4ea473..614b09b28e 100644 --- a/src/frontend/platform/public/locales/en-US/permission.json +++ b/src/frontend/platform/public/locales/en-US/permission.json @@ -73,7 +73,15 @@ "pendingChanges_other": "{{count}} changes are not published yet", "publishChanges": "Publish changes", "discardChanges": "Discard changes", - "draftFailed": "Could not prepare the change draft. Try again." + "draftFailed": "Could not prepare the change draft. Try again.", + "appliesTo": "Applies to", + "showChangeList": "Show details", + "hideChangeList": "Hide details", + "changeSummary": { + "level": "{{name}}: {{from}} → {{to}}", + "enabled": "{{name}}: enabled", + "disabled": "{{name}}: disabled" + } }, "model": { "title": "Permission Model", @@ -83,7 +91,7 @@ "actions": "Included Actions", "action": "Model action", "active": "Enable Model", - "inactive": "Model inactive", + "inactive": "Off", "level": "Derived Level", "allowSameLevel": "Allow Same-level Grants", "create": "New Model", @@ -103,7 +111,12 @@ "confirmDelete": "Delete the model \"{{name}}\"? This takes effect immediately and cannot be undone.", "deleteFailed": "Delete failed. Refresh and try again", "deleteBlockedByGrants": "{{count}} grant(s) still use this permission model. Move them to another model before deleting it.", - "disableBeforeDelete": "Turn the model off before deleting it" + "disableBeforeDelete": "Turn the model off before deleting it", + "description": "Tick the actions this model covers. Its level is the highest action in the selection.", + "derivedLevel": "Model level", + "activeHint": "Switched off, it can no longer be granted; existing grants are unaffected.", + "allowSameLevelHint": "Holders may grant this same tier to others.", + "allowSameLevelUnavailable": "Select the manage-permission action first." }, "impact": { "title": "Confirm Publish Impact", diff --git a/src/frontend/platform/public/locales/ja/api_errors.json b/src/frontend/platform/public/locales/ja/api_errors.json index 8a5ed4443e..524b778882 100644 --- a/src/frontend/platform/public/locales/ja/api_errors.json +++ b/src/frontend/platform/public/locales/ja/api_errors.json @@ -149,6 +149,10 @@ "11010": "SOPファイル形式が不正です", "11011": "SOPの推薦事例設定に失敗しました", "11020": "ファイルアップロードに失敗しました", + "11021": "フォルダーに含められるファイルは最大 100 件です。減らして再アップロードしてください。", + "11022": "フォルダーの合計サイズが 500MB の上限を超えています。減らして再アップロードしてください。", + "11023": "フォルダーの階層が 10 階層の上限を超えています。構造を簡素化して再アップロードしてください。", + "11024": "ファイルサイズがアップロード上限を超えています。圧縮または分割して再試行してください。", "11030": "利用回数の上限に達しました。新しい招待コードが必要です", "11040": "ユーザー質問の送信に失敗しました", "11050": "ベクトル検索モデルの問題です。管理者に相談してください", diff --git a/src/frontend/platform/public/locales/ja/bs.json b/src/frontend/platform/public/locales/ja/bs.json index c5f04153dd..23fcb50e37 100644 --- a/src/frontend/platform/public/locales/ja/bs.json +++ b/src/frontend/platform/public/locales/ja/bs.json @@ -511,6 +511,8 @@ "fileUploadFailed": "ファイルのアップロードに失敗しました: {{name}}", "fileExceedRemoved": "ファイル:{{name}} は {{size}}M を超えているため削除されました", "fileTypeNotAllowed": "サポートされていないファイル形式:{{type}}", + "mediaFileTooMany": "アップロードファイル数は5個を超えられません。", + "noFileSelected": "ファイルが選択されていません", "uploadFailedCheckFormat": "アップロードに失敗しました。ファイル形式を確認してください", "sourceTooltip": "出典段落", "filterLabel": "ラベルでフィルター", @@ -2026,5 +2028,30 @@ "githubRateLimit": "GitHub のレート制限に達しました。しばらくしてから再試行してください", "unknown": "操作に失敗しました。再試行してください" } + }, + "citation": { + "unsupportedPreview": "この形式のファイルはプレビューできません", + "loadingPreview": "ドキュメントプレビューを読み込み中...", + "noPreviewUrl": "プレビュー可能なファイルアドレスがありません", + "documentPreview": "ドキュメントプレビュー", + "downloadDocument": "ドキュメントをダウンロード", + "closeDocumentPreview": "ドキュメントプレビューを閉じる", + "web": "ウェブ", + "document": "ドキュメント", + "untitled": "タイトルなし", + "loadingDetail": "出典の詳細を読み込み中...", + "loadDetailFailed": "出典の詳細の読み込みに失敗しました", + "policyDocument": "政策文書", + "references": "参考資料", + "closeReferences": "参考資料を閉じる", + "backToReferences": "参考資料一覧に戻る", + "noDownloadUrl": "ダウンロード可能なファイルアドレスがありません" + }, + "mediaPreview": { + "recognizedText": "認識テキスト", + "entryText": "登録テキスト", + "webPreview": "ウェブプレビュー", + "openSourcePage": "元のページを開く", + "noWebSnapshot": "ウェブスナップショットがありません。登録テキストを確認するか、元のページを開いてください。" } } diff --git a/src/frontend/platform/public/locales/ja/permission.json b/src/frontend/platform/public/locales/ja/permission.json index f9f8897044..72803f3cfe 100644 --- a/src/frontend/platform/public/locales/ja/permission.json +++ b/src/frontend/platform/public/locales/ja/permission.json @@ -73,7 +73,15 @@ "pendingChanges_other": "未公開の変更が {{count}} 件あります", "publishChanges": "変更を公開", "discardChanges": "変更を破棄", - "draftFailed": "変更ドラフトの作成に失敗しました。再試行してください。" + "draftFailed": "変更ドラフトの作成に失敗しました。再試行してください。", + "appliesTo": "対象リソース", + "showChangeList": "詳細を表示", + "hideChangeList": "詳細を隠す", + "changeSummary": { + "level": "{{name}}:{{from}} → {{to}}", + "enabled": "{{name}}:有効化", + "disabled": "{{name}}:無効化" + } }, "model": { "title": "権限モデル", @@ -83,7 +91,7 @@ "actions": "含まれるアクション", "action": "モデルアクション", "active": "モデルを有効化", - "inactive": "モデルは無効です", + "inactive": "無効", "level": "派生レベル", "allowSameLevel": "同レベルへの付与を許可", "create": "モデルを作成", @@ -103,7 +111,12 @@ "confirmDelete": "モデル「{{name}}」を削除しますか?即時に反映され、元に戻せません。", "deleteFailed": "削除に失敗しました。再読み込みしてやり直してください", "deleteBlockedByGrants": "この権限モデルはまだ {{count}} 件の権限付与で使用されています。先に他のモデルへ移してください", - "disableBeforeDelete": "削除する前にモデルを無効化してください" + "disableBeforeDelete": "削除する前にモデルを無効化してください", + "description": "このモデルに含める操作を選択します。レベルは選択した操作の最上位で決まります。", + "derivedLevel": "モデルレベル", + "activeHint": "無効にすると新たに付与できません。既存の付与は影響を受けません。", + "allowSameLevelHint": "保持者が同じレベルの権限を他者へ付与できます。", + "allowSameLevelUnavailable": "先に「権限管理」操作を選択してください。" }, "impact": { "title": "公開影響の確認", diff --git a/src/frontend/platform/public/locales/zh-Hans/api_errors.json b/src/frontend/platform/public/locales/zh-Hans/api_errors.json index d7921d71b1..0d30eda8e2 100644 --- a/src/frontend/platform/public/locales/zh-Hans/api_errors.json +++ b/src/frontend/platform/public/locales/zh-Hans/api_errors.json @@ -149,6 +149,10 @@ "11010": "SOP文件格式不符合要求", "11011": "SOP设置精选案例失败", "11020": "文件上传失败", + "11021": "文件夹最多包含 100 个文件,请精简后重新上传", + "11022": "文件夹总大小超过 500MB 上限,请精简后重新上传", + "11023": "文件夹层级超过 10 层上限,请精简目录结构后重新上传", + "11024": "文件大小超过上传限制,请压缩或拆分后重试", "11030": "您的$t(linsight)使用次数已用完,请使用新的邀请码激活$t(linsight)功能", "11040": "提交$t(linsight)用户问题失败", "11050": "请联系管理员检查工作台向量检索模型状态", diff --git a/src/frontend/platform/public/locales/zh-Hans/bs.json b/src/frontend/platform/public/locales/zh-Hans/bs.json index 095dddfa66..422c46809a 100644 --- a/src/frontend/platform/public/locales/zh-Hans/bs.json +++ b/src/frontend/platform/public/locales/zh-Hans/bs.json @@ -516,6 +516,8 @@ "fileUploadFailed": "文件上传失败: {{name}}", "fileExceedRemoved": "文件:{{name}}超过{{size}}M,已移除", "fileTypeNotAllowed": "不支持文件类型:{{type}}", + "mediaFileTooMany": "上传文件个数不能超过5个", + "noFileSelected": "没有选择文件", "uploadFailedCheckFormat": "上传失败,请检查文件格式", "sourceTooltip": "来源段落", "filterLabel": "筛选标签", @@ -2026,5 +2028,30 @@ "githubRateLimit": "GitHub 访问已达速率限制,请稍后再试", "unknown": "操作失败,请重试" } + }, + "citation": { + "unsupportedPreview": "该类型文件不支持预览", + "loadingPreview": "加载文档预览...", + "noPreviewUrl": "暂无可预览文件地址", + "documentPreview": "文档预览", + "downloadDocument": "下载文档", + "closeDocumentPreview": "关闭文档预览", + "web": "网页", + "document": "文档", + "untitled": "暂无标题", + "loadingDetail": "加载溯源详情...", + "loadDetailFailed": "溯源详情加载失败", + "policyDocument": "政策文件", + "references": "参考资料", + "closeReferences": "关闭参考资料", + "backToReferences": "返回参考资料列表", + "noDownloadUrl": "暂无可下载文件地址" + }, + "mediaPreview": { + "recognizedText": "识别文本", + "entryText": "入库文本", + "webPreview": "网页预览", + "openSourcePage": "打开原网页", + "noWebSnapshot": "暂无网页快照,请查看入库文本或打开原网页。" } } diff --git a/src/frontend/platform/public/locales/zh-Hans/permission.json b/src/frontend/platform/public/locales/zh-Hans/permission.json index e93c0ebe05..e2c7d3b02d 100644 --- a/src/frontend/platform/public/locales/zh-Hans/permission.json +++ b/src/frontend/platform/public/locales/zh-Hans/permission.json @@ -73,7 +73,15 @@ "pendingChanges_other": "有 {{count}} 项改动尚未发布", "publishChanges": "发布更改", "discardChanges": "放弃更改", - "draftFailed": "生成变更草案失败,请重试" + "draftFailed": "生成变更草案失败,请重试", + "appliesTo": "适用资源", + "showChangeList": "查看明细", + "hideChangeList": "收起明细", + "changeSummary": { + "level": "{{name}}:{{from}} → {{to}}", + "enabled": "{{name}}:启用", + "disabled": "{{name}}:停用" + } }, "model": { "title": "权限模型", @@ -83,7 +91,7 @@ "actions": "包含的动作", "action": "模型动作", "active": "启用模型", - "inactive": "模型已停用", + "inactive": "已停用", "level": "派生等级", "allowSameLevel": "允许同级授权", "create": "新建模型", @@ -103,7 +111,12 @@ "confirmDelete": "确认删除模型「{{name}}」?删除后立即生效,不可恢复。", "deleteFailed": "删除失败,请刷新后重试", "deleteBlockedByGrants": "该权限模型还有 {{count}} 处授权在使用,先把这些授权改到其他模型再删除", - "disableBeforeDelete": "先关闭「启用」才能删除" + "disableBeforeDelete": "先关闭「启用」才能删除", + "description": "勾选这个模型包含哪些动作,等级由其中最高的动作决定。", + "derivedLevel": "模型等级", + "activeHint": "关闭后不能再用它授权,已有授权不受影响。", + "allowSameLevelHint": "开启后,持有此模型的人可以把同等级权限授予别人。", + "allowSameLevelUnavailable": "需要先勾选「管理权限」动作。" }, "impact": { "title": "发布影响确认", diff --git a/src/frontend/platform/src/components/bs-comp/chatComponent/CitationDocumentPreviewDrawer.tsx b/src/frontend/platform/src/components/bs-comp/chatComponent/CitationDocumentPreviewDrawer.tsx index 81b69065cd..ca979fba2e 100644 --- a/src/frontend/platform/src/components/bs-comp/chatComponent/CitationDocumentPreviewDrawer.tsx +++ b/src/frontend/platform/src/components/bs-comp/chatComponent/CitationDocumentPreviewDrawer.tsx @@ -1,5 +1,6 @@ // @ts-strict-ignore import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Download, FileText, Loader2, X } from "lucide-react"; import FileView from "@/components/bs-comp/FileView"; import { cname } from "@/components/bs-ui/utils"; @@ -7,6 +8,7 @@ import { FileIcon } from "@/components/bs-icons/file"; import { ExcelPreview } from "@bisheng/file-viewers"; import DocxPreview from "@/pages/KnowledgePage/components/DocxFileViewer"; import TxtFileViewer from "@/pages/KnowledgePage/components/TxtFileViewer"; +import { MediaTranscriptTabs } from "@/pages/KnowledgePage/components/RichPreviewFile"; import { getCitationDetail, type ChatCitation } from "@/controllers/API"; import { getCitationDocumentDownloadUrl, @@ -14,6 +16,7 @@ import { getCitationDocumentName, getCitationDocumentPreviewUrl, getCitationItemBBoxes, + isMediaCitation, isRagCitation, isRagCitationMissingPreviewUrl, toAbsolutePreviewUrl, @@ -84,6 +87,33 @@ function resolveFileType(detail: ChatCitation, rawUrl: string) { return name.split(".").pop()?.toLowerCase() || ""; } +/** Audio/video citation: the clip itself plus the transcript the answer quoted, + * the same pairing the knowledge space shows. */ +const VIDEO_CITATION_EXTENSIONS = new Set(["mp4", "mov", "avi", "mkv", "webm"]); + +function MediaPreview({ fileUrl, transcriptUrl, isVideo }: { fileUrl: string; transcriptUrl: string; isVideo: boolean }) { + return ( +
+
+
+ {isVideo ? ( +
+
+ {transcriptUrl ? ( +
+ +
+ ) : null} +
+ ); +} + function buildPdfLabels(bboxes: CitationPdfBBox[]) { const labels: Record = {}; bboxes.forEach((item, index) => { @@ -107,7 +137,9 @@ function renderPreviewContent({ fileName, bboxes, targetBBox, + t, }: { + t: (key: string) => string; fileType: string; fileUrl: string; fileName: string; @@ -156,7 +188,7 @@ function renderPreviewContent({ return (
-
该类型文件不支持预览
+
{t("citation.unsupportedPreview")}
); } @@ -223,6 +255,7 @@ export function CitationDocumentPreviewContent({ compactMode = false, className, }: CitationDocumentPreviewContentProps) { + const { t } = useTranslation(); const { effectiveDetail, isResolving } = useResolvedCitationDetail(preview); if (!preview || !isRagCitation(effectiveDetail)) { @@ -231,9 +264,16 @@ export function CitationDocumentPreviewContent({ const { itemId, locateChunk } = preview; const fileName = getCitationDocumentName(effectiveDetail); - const rawFileUrl = getCitationDocumentPreviewUrl(effectiveDetail); + const isMedia = isMediaCitation(effectiveDetail); + // A clip renders from the original file (the player), with its transcript — + // which is what the preview URL points at — beside it. Everything else + // renders from the preview stand-in. + const rawFileUrl = isMedia + ? getCitationDocumentDownloadUrl(effectiveDetail) + : getCitationDocumentPreviewUrl(effectiveDetail); const fileType = resolveFileType(effectiveDetail, rawFileUrl); const fileUrl = toAbsolutePreviewUrl(rawFileUrl); + const transcriptUrl = isMedia ? toAbsolutePreviewUrl(getCitationDocumentPreviewUrl(effectiveDetail)) : ""; const shouldLocateChunk = locateChunk && fileType === "pdf"; const bboxes: CitationPdfBBox[] = shouldLocateChunk ? getCitationItemBBoxes(effectiveDetail, itemId) : []; const targetBBox = bboxes[0] ?? null; @@ -243,15 +283,23 @@ export function CitationDocumentPreviewContent({ {isResolving ? (
- 加载文档预览... + {t("citation.loadingPreview")} +
+ ) : isMedia && fileUrl ? ( +
+
) : fileUrl ? (
- {renderPreviewContent({ fileType, fileUrl, fileName, bboxes, targetBBox })} + {renderPreviewContent({ fileType, fileUrl, fileName, bboxes, targetBBox, t })}
) : (
- 暂无可预览文件地址 + {t("citation.noPreviewUrl")}
)}
@@ -262,6 +310,7 @@ export default function CitationDocumentPreviewDrawer({ preview, onClose, }: CitationDocumentPreviewDrawerProps) { + const { t } = useTranslation(); const { effectiveDetail } = useResolvedCitationDetail(preview); const isPhoneViewport = useMediaQuery("(max-width: 576px)"); const isNarrowLayout = useMediaQuery("(max-width: 768px)"); @@ -327,7 +376,7 @@ export default function CitationDocumentPreviewDrawer({ isFullBleedMobile && "inset-0 z-[120] overflow-hidden overscroll-contain touch-pan-y", !isFullBleedMobile && "inset-y-0 right-0 z-[121] w-[min(520px,calc(100vw-24px))] border-l border-[#E5E6EB] shadow-[0_8px_28px_rgba(0,0,0,0.16)]", )} - aria-label="文档预览" + aria-label={t("citation.documentPreview")} onClick={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > @@ -357,7 +406,7 @@ export default function CitationDocumentPreviewDrawer({ "shrink-0 items-center justify-center text-[#86909C] hover:bg-[#F2F3F5] hover:text-[#335CFF] disabled:cursor-not-allowed disabled:text-[#C9CDD4]", isFullBleedMobile ? "inline-flex size-8 rounded-md" : "inline-flex size-6 rounded-[6px]", )} - aria-label="下载文档" + aria-label={t("citation.downloadDocument")} > @@ -370,7 +419,7 @@ export default function CitationDocumentPreviewDrawer({ onClick={handleDownload} disabled={!downloadFileUrl} className="inline-flex size-8 shrink-0 items-center justify-center rounded-[6px] text-[#86909C] hover:bg-[#F2F3F5] hover:text-[#335CFF] disabled:cursor-not-allowed disabled:text-[#C9CDD4]" - aria-label="下载文档" + aria-label={t("citation.downloadDocument")} > @@ -382,7 +431,7 @@ export default function CitationDocumentPreviewDrawer({ "items-center justify-center text-[#A9AEB8] hover:bg-[#F2F3F5] hover:text-[#4E5969]", isFullBleedMobile ? "inline-flex size-8 rounded-md" : "inline-flex size-6 rounded-[6px]", )} - aria-label="关闭文档预览" + aria-label={t("citation.closeDocumentPreview")} > diff --git a/src/frontend/platform/src/components/bs-comp/chatComponent/CitationReferencesDrawer.tsx b/src/frontend/platform/src/components/bs-comp/chatComponent/CitationReferencesDrawer.tsx index 4bac4cc1a6..5019a91606 100644 --- a/src/frontend/platform/src/components/bs-comp/chatComponent/CitationReferencesDrawer.tsx +++ b/src/frontend/platform/src/components/bs-comp/chatComponent/CitationReferencesDrawer.tsx @@ -1,7 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { ChevronLeft, ChevronRight, Download, Loader2, X } from "lucide-react"; import { getCitationDetail, resolveCitationDetails, type ChatCitation } from "@/controllers/API"; import { cname } from "@/components/bs-ui/utils"; +import { toast } from "@/components/bs-ui/toast/use-toast"; import { buildCitationDocumentPreview, buildCitationReferenceItems, @@ -62,6 +64,7 @@ function useMediaQuery(query: string) { } function SourceTypeBadge({ preview, type }: { preview: CitationPreview | null; type?: string }) { + const { t } = useTranslation(); const isWeb = normalizeCitationType(preview?.type || type) === "web"; return (
- {isWeb ? "网页" : "文档"} + {isWeb ? t("citation.web") : t("citation.document")}
); } @@ -108,10 +111,11 @@ function CitationReferenceCard({ hasError: boolean; onOpenDocumentPreview: (item: CitationReferenceItem, detail: ChatCitation) => void; }) { + const { t } = useTranslation(); const preview = item.legacyPreview ?? buildCitationDocumentPreview(detail, item.data); const type = preview?.type || item.data.type; const isWeb = normalizeCitationType(type) === "web"; - const title = preview?.title || "暂无标题"; + const title = preview?.title || t("citation.untitled"); const canOpenDocument = !!detail && isRagCitation(detail, type); const { name: documentName, extension: documentExtension } = splitDocumentTitle(title, detail, preview); @@ -169,10 +173,10 @@ function CitationReferenceCard({ {isLoading ? ( - 加载溯源详情... + {t("citation.loadingDetail")} ) : hasError ? ( - 溯源详情加载失败 + {t("citation.loadDetailFailed")} ) : ( null )} @@ -184,13 +188,13 @@ function CitationReferenceCard({
- {preview?.sourceName || "网页"} + {preview?.sourceName || t("citation.web")} {preview?.sourceMeta ? {preview.sourceMeta} : null} ) : ( <> - {preview?.sourceName || "政策文件"} + {preview?.sourceName || t("citation.policyDocument")} )}
@@ -205,6 +209,7 @@ export default function CitationReferencesDrawer({ buttonClassName, allowRemoteCitationResolve = true, }: CitationReferencesDrawerProps) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); const [detailMap, setDetailMap] = useState>(() => createCitationDetailMap(citations)); const [loadingMap, setLoadingMap] = useState>({}); @@ -428,14 +433,24 @@ export default function CitationReferencesDrawer({ setPanelView("document-preview"); }; - const handleDownloadDocument = () => { + const handleDownloadDocument = async () => { if (!documentPreview) { return; } - const fileName = getCitationDocumentName(documentPreview.detail); - const fileUrl = toAbsolutePreviewUrl(getCitationDocumentDownloadUrl(documentPreview.detail)); + const detail = documentPreview.detail; + const fileName = getCitationDocumentName(detail); + // The panel holds the citation as it arrived with the message, and that + // payload carries no file URL until it is resolved. The preview body + // resolves on its own, so the file showed up while this button silently + // did nothing — resolve here too before giving up. + let rawFileUrl = getCitationDocumentDownloadUrl(detail); + if (!rawFileUrl && detail?.citationId) { + rawFileUrl = getCitationDocumentDownloadUrl(await loadCitationDetail(detail.citationId)); + } + const fileUrl = toAbsolutePreviewUrl(rawFileUrl); if (!fileUrl) { + toast({ variant: "error", description: t("citation.noDownloadUrl") }); return; } @@ -450,7 +465,7 @@ export default function CitationReferencesDrawer({ const documentHeaderTitle = documentPreview ? splitDocumentTitle(getCitationDocumentName(documentPreview.detail), documentPreview.detail, null) - : { name: "文档预览", extension: "" }; + : { name: t("citation.documentPreview"), extension: "" }; const referenceListContent = ( <> @@ -459,7 +474,7 @@ export default function CitationReferencesDrawer({ isNarrowLayout ? "h-11 px-2" : "h-14 px-3", )}>
-

参考资料

+

{t("citation.references")}

{references.length} @@ -471,7 +486,7 @@ export default function CitationReferencesDrawer({ "inline-flex items-center justify-center text-[#A9AEB8] hover:bg-[#F2F3F5] hover:text-[#4E5969]", isNarrowLayout ? "size-8 rounded-md" : "size-6 rounded-[6px]", )} - aria-label="关闭参考资料" + aria-label={t("citation.closeReferences")} > @@ -506,7 +521,7 @@ export default function CitationReferencesDrawer({ setDocumentPreview(null); }} className="inline-flex size-6 shrink-0 items-center justify-center rounded-[6px] text-[#4E5969] hover:bg-[#F2F3F5]" - aria-label="返回参考资料列表" + aria-label={t("citation.backToReferences")} > @@ -528,7 +543,7 @@ export default function CitationReferencesDrawer({ type="button" onClick={handleDownloadDocument} className="inline-flex size-6 shrink-0 items-center justify-center rounded-[6px] text-[#024DE3] transition-colors hover:bg-[#F2F7FF]" - aria-label="下载文档" + aria-label={t("citation.downloadDocument")} > @@ -536,7 +551,7 @@ export default function CitationReferencesDrawer({ type="button" onClick={handleClosePanel} className="inline-flex size-6 shrink-0 items-center justify-center rounded-[6px] text-[#A9AEB8] transition-colors hover:bg-[#F7F8FA]" - aria-label="关闭参考资料" + aria-label={t("citation.closeReferences")} > @@ -569,7 +584,7 @@ export default function CitationReferencesDrawer({
- 参考资料 + {t("citation.references")}
@@ -578,7 +593,7 @@ export default function CitationReferencesDrawer({ isFullBleedMobile ? ( @@ -586,7 +601,7 @@ export default function CitationReferencesDrawer({
-
-
- - -
- {mediaTextUrl ? ( -
- -
- ) : null} -
+
); @@ -217,20 +234,20 @@ export default function RichPreviewFile({ file, previewData }: { file: any; prev onClick={() => setWebTab("html")} className={`h-8 rounded-md px-3 text-sm ${webTab === "html" ? "bg-primary text-white" : "bg-gray-100 text-gray-600"}`} > - 网页预览 + {t("mediaPreview.webPreview")} {sourceUrl ? ( - 打开原网页 + {t("mediaPreview.openSourcePage")} ) : null} @@ -238,7 +255,7 @@ export default function RichPreviewFile({ file, previewData }: { file: any; prev {webTab === "html" ? ( htmlUrl ? : (
- 暂无网页快照,请查看入库文本或打开原网页。 + {t("mediaPreview.noWebSnapshot")}
) ) : textUrl ? ( diff --git a/src/frontend/platform/src/pages/SystemPage/components/RolesAndPermissions.tsx b/src/frontend/platform/src/pages/SystemPage/components/RolesAndPermissions.tsx index 571d47d292..eda62895a3 100644 --- a/src/frontend/platform/src/pages/SystemPage/components/RolesAndPermissions.tsx +++ b/src/frontend/platform/src/pages/SystemPage/components/RolesAndPermissions.tsx @@ -52,7 +52,7 @@ function CatalogState({ loading, error, onRetry }: CatalogStateProps) { ))} @@ -210,13 +219,17 @@ export function RolesAndPermissions() { const handleCreateDraft = async ( changes: PermissionCatalogChange[], + config: { silent?: boolean } = {}, ): Promise => { if (!catalog) throw new Error("permission Catalog is not loaded") - return await createPermissionCatalogDraftApi({ - idempotency_key: createIdempotencyKey("catalog-draft"), - base_release_id: catalog.id, - changes, - }) + return await createPermissionCatalogDraftApi( + { + idempotency_key: createIdempotencyKey("catalog-draft"), + base_release_id: catalog.id, + changes, + }, + config, + ) } const handleReviewImpact = (draft: PermissionCatalogDraft) => { @@ -244,12 +257,20 @@ export function RolesAndPermissions() { ] : [{ type: "DELETE_MODEL", model_key: modelKey }] try { - const draft = await handleCreateDraft(changes) - await handlePublish(draft.draft_id, { - expected_current_release_id: catalog.id, - idempotency_key: createIdempotencyKey("catalog-publish"), - confirmed: true, - }) + // Deletion is refused while drafting, not at publish — ask for the + // envelope on both legs or the reason is lost on the first one. + const draft = await handleCreateDraft(changes, { silent: true }) + await handlePublish( + draft.draft_id, + { + expected_current_release_id: catalog.id, + idempotency_key: createIdempotencyKey("catalog-publish"), + confirmed: true, + }, + // Ask for the envelope: the failure below needs the reason the server + // sent, and the default rejection is a bare message string. + { silent: true }, + ) setSelectedModelKey(null) } catch (error) { // Nothing else reports this: the request layer only auto-toasts a couple of @@ -257,7 +278,9 @@ export function RolesAndPermissions() { // leave the model in place with no explanation. 25004 covers several model // -state conflicts, so name the one in the way — "state does not allow // this" leaves the author with nothing to act on. - const detail = (error as { data?: { reason?: string; reference_count?: number } })?.data + const detail = ( + error as { data?: { reason?: string; reference_count?: number } } | null + )?.data message({ variant: "error", description: @@ -271,8 +294,9 @@ export function RolesAndPermissions() { const handlePublish = async ( draftId: number, payload: PublishPermissionCatalogDraftRequest, + config: { silent?: boolean } = {}, ) => { - await publishPermissionCatalogDraftApi(draftId, payload) + await publishPermissionCatalogDraftApi(draftId, payload, config) setImpactDraft(null) await loadCatalog() message({ diff --git a/src/frontend/platform/src/pages/SystemPage/components/permission/ActionLevelBoard.tsx b/src/frontend/platform/src/pages/SystemPage/components/permission/ActionLevelBoard.tsx index 7755ae84dd..4ddd26186d 100644 --- a/src/frontend/platform/src/pages/SystemPage/components/permission/ActionLevelBoard.tsx +++ b/src/frontend/platform/src/pages/SystemPage/components/permission/ActionLevelBoard.tsx @@ -1,12 +1,18 @@ import { Button } from "@/components/bs-ui/button" import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/bs-ui/select" + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/bs-ui/dropdownMenu" import { Switch } from "@/components/bs-ui/switch" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/bs-ui/tooltip" import type { PermissionActionLevel, PermissionCatalogAction, @@ -14,8 +20,8 @@ import type { PermissionCatalogDraft, } from "@/controllers/API/permission" import { cn } from "@/utils" -import { GripVertical, ShieldAlert } from "lucide-react" -import { useEffect, useMemo, useState } from "react" +import { ChevronDown, GripVertical, Info } from "lucide-react" +import { useCallback, useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" import { actionLabel, resourceTypeLabel } from "./actionLabels" @@ -60,6 +66,8 @@ export function ActionLevelBoard({ const [activeStates, setActiveStates] = useState>({}) const [submitting, setSubmitting] = useState(false) const [draftFailed, setDraftFailed] = useState(false) + const [showChangeList, setShowChangeList] = useState(false) + const [draggingCode, setDraggingCode] = useState(null) const resetToRelease = () => { setLevels( @@ -73,10 +81,19 @@ export function ActionLevelBoard({ ), ) setDraftFailed(false) + setShowChangeList(false) } useEffect(resetToRelease, [normalizedActions]) + const levelName = useCallback( + (level: ActionLevelValue) => + level === null + ? t("actionLevel.unassigned") + : t("actionLevel.level", { level }), + [t], + ) + // Derived from the diff against the published release rather than accumulated // per edit, so moving a card back where it came from drops the change instead // of queueing a second one. @@ -103,6 +120,34 @@ export function ActionLevelBoard({ return changes }, [normalizedActions, levels, activeStates]) + // "3 changes" tells the author how much is pending but not what it is, and the + // impact dialog only counts affected resources. Spell each edit out before + // anyone commits to publishing it. + const changeSummaries = useMemo(() => { + const byCode = new Map( + normalizedActions.map((action) => [action.code, action]), + ) + return pendingChanges.map((change) => { + const action = byCode.get(change.action_code!) + const name = action + ? actionLabel(t, action.code, action.name) + : change.action_code! + if (change.type === "ASSIGN_ACTION_LEVEL") { + return t("actionLevel.changeSummary.level", { + name, + from: levelName(action?.level ?? null), + to: levelName(change.level ?? null), + }) + } + return t( + change.active + ? "actionLevel.changeSummary.enabled" + : "actionLevel.changeSummary.disabled", + { name }, + ) + }) + }, [levelName, normalizedActions, pendingChanges, t]) + const handleLevelChange = (actionCode: string, level: ActionLevelValue) => { if (disabled || submitting || levels[actionCode] === level) return setDraftFailed(false) @@ -146,7 +191,7 @@ export function ActionLevelBoard({ + + {showChangeList && ( +
    + {changeSummaries.map((summary) => ( +
  • + {summary} +
  • + ))} +
+ )} )} {draftFailed && (

{t("actionLevel.draftFailed")} @@ -187,158 +253,173 @@ export function ActionLevelBoard({ )} {/* The only scroll area on this tab: the level columns, header and banners stay put. */} -

- {LEVELS.map((level) => { - const key = levelKey(level) - const zoneActions = normalizedActions.filter( - (action) => levels[action.code] === level, - ) - return ( -
{ - event.preventDefault() - event.dataTransfer.dropEffect = "move" - }} - onDrop={(event) => { - event.preventDefault() - const actionCode = event.dataTransfer.getData("text/plain") - if (actionCode) handleLevelChange(actionCode, level) - }} - className={cn( - "min-h-56 rounded-xl border bg-muted/30 p-3 transition-colors", - level === null - ? "border-dashed border-amber-300 bg-amber-50/60" - : "border-border", - )} - role="region" - aria-label={ - level === null - ? t("actionLevel.unassigned") - : t("actionLevel.level", { level }) - } - > -
-

- {level === null - ? t("actionLevel.unassigned") - : t("actionLevel.level", { level })} -

- - {zoneActions.length} - -
+ +
+ {LEVELS.map((level) => { + const key = levelKey(level) + const zoneActions = normalizedActions.filter( + (action) => levels[action.code] === level, + ) + const isDropTarget = + draggingCode !== null && levels[draggingCode] !== level + return ( +
{ + event.preventDefault() + event.dataTransfer.dropEffect = "move" + }} + onDrop={(event) => { + event.preventDefault() + const actionCode = event.dataTransfer.getData("text/plain") + if (actionCode) handleLevelChange(actionCode, level) + setDraggingCode(null) + }} + className={cn( + "flex min-h-56 flex-col rounded-xl border bg-muted/30 p-3 transition-colors", + // Unassigned is a normal state, not a warning — it reads as one + // more column, distinguished by a dashed edge alone. + level === null ? "border-dashed" : "border-border", + isDropTarget && "border-primary bg-primary/5", + )} + role="region" + aria-label={levelName(level)} + > +
+

+ {levelName(level)} +

+ + {zoneActions.length} + +
-
- {zoneActions.map((action) => { - const active = activeStates[action.code] ?? action.active - return ( -
{ - event.dataTransfer.effectAllowed = "move" - event.dataTransfer.setData("text/plain", action.code) - }} - className={cn( - "rounded-lg border bg-background p-3 shadow-sm transition-opacity", - submitting && "opacity-60", - )} - > -
-
- ) - })} + + ) + })} - {zoneActions.length === 0 && ( -

- {t("actionLevel.empty")} -

- )} + {zoneActions.length === 0 && ( +

+ {t("actionLevel.empty")} +

+ )} +
-
- ) - })} -
+ ) + })} +
+ ) } diff --git a/src/frontend/platform/src/pages/SystemPage/components/permission/ImpactDialog.tsx b/src/frontend/platform/src/pages/SystemPage/components/permission/ImpactDialog.tsx index b753caf98b..c85a796905 100644 --- a/src/frontend/platform/src/pages/SystemPage/components/permission/ImpactDialog.tsx +++ b/src/frontend/platform/src/pages/SystemPage/components/permission/ImpactDialog.tsx @@ -172,7 +172,7 @@ export function ImpactDialog({ )} - - -
{!isStandard && !createMode && ( @@ -398,7 +491,7 @@ export function ModelEditor({
) }, @@ -97,6 +108,10 @@ vi.mock("@/pages/SystemPage/components/permission/ImpactDialog", () => ({ }, })) +vi.mock("@/components/bs-ui/toast/use-toast", () => ({ + message: (...args: unknown[]) => childCalls.message(...args), +})) + vi.mock("@/controllers/API/permission", () => ({ createPermissionCatalogDraftApi: vi.fn(), getPermissionCatalogApi: vi.fn(), @@ -233,27 +248,34 @@ describe("F048 RolesAndPermissions", () => { ) await waitFor(() => { - expect(createPermissionCatalogDraftApi).toHaveBeenCalledWith({ - idempotency_key: expect.stringMatching(/^catalog-draft-/), - base_release_id: 21, - changes: [ - { - type: "ASSIGN_ACTION_LEVEL", - action_code: "edit", - level: 2, - }, - ], - }) + expect(createPermissionCatalogDraftApi).toHaveBeenCalledWith( + { + idempotency_key: expect.stringMatching(/^catalog-draft-/), + base_release_id: 21, + changes: [ + { + type: "ASSIGN_ACTION_LEVEL", + action_code: "edit", + level: 2, + }, + ], + }, + {}, + ) }) fireEvent.click( await screen.findByRole("button", { name: "impact-dialog.publish" }), ) await waitFor(() => { - expect(publishPermissionCatalogDraftApi).toHaveBeenCalledWith(31, { - expected_current_release_id: 21, - idempotency_key: "catalog-publish-test", - confirmed: true, - }) + expect(publishPermissionCatalogDraftApi).toHaveBeenCalledWith( + 31, + { + expected_current_release_id: 21, + idempotency_key: "catalog-publish-test", + confirmed: true, + }, + {}, + ) expect(getPermissionCatalogApi).toHaveBeenCalledTimes(2) }) }) @@ -287,4 +309,56 @@ describe("F048 RolesAndPermissions", () => { }), ) }) + + it("names the blocker when a model cannot be deleted", async () => { + // 25004 covers several model-state conflicts, so its shared copy says only + // "state does not allow this". The count is the actionable part: it tells + // the author how much has to be moved off the model first. + // + // The refusal lands on the *draft* leg — the batch is validated as it is + // drafted, so a fix that only listened to the publish leg never saw it. + vi.mocked(createPermissionCatalogDraftApi).mockRejectedValueOnce({ + status_code: 25004, + data: { reason: "referenced_by_grants", reference_count: 3 }, + }) + + renderWithUser({ role: "admin", is_global_super: true }) + await waitFor(() => expect(getPermissionCatalogApi).toHaveBeenCalled()) + const modelsTab = screen.getByRole("tab", { name: "catalog.models" }) + fireEvent.mouseDown(modelsTab) + fireEvent.click(modelsTab) + + fireEvent.click(await screen.findByText("model-editor.delete")) + + await waitFor(() => { + expect(childCalls.message).toHaveBeenCalledWith({ + variant: "error", + description: "model.deleteBlockedByGrants", + }) + }) + }) + + it("falls back to the plain failure when the server sends no reason", async () => { + vi.mocked(publishPermissionCatalogDraftApi).mockRejectedValueOnce( + "some other failure", + ) + vi.mocked(createPermissionCatalogDraftApi).mockResolvedValueOnce({ + draft_id: 31, + } as never) + + renderWithUser({ role: "admin", is_global_super: true }) + await waitFor(() => expect(getPermissionCatalogApi).toHaveBeenCalled()) + const modelsTab = screen.getByRole("tab", { name: "catalog.models" }) + fireEvent.mouseDown(modelsTab) + fireEvent.click(modelsTab) + + fireEvent.click(await screen.findByText("model-editor.delete")) + + await waitFor(() => { + expect(childCalls.message).toHaveBeenCalledWith({ + variant: "error", + description: "model.deleteFailed", + }) + }) + }) }) diff --git a/src/frontend/platform/src/test/test-utils.tsx b/src/frontend/platform/src/test/test-utils.tsx index 352d629b4c..aa8dc68c55 100644 --- a/src/frontend/platform/src/test/test-utils.tsx +++ b/src/frontend/platform/src/test/test-utils.tsx @@ -59,6 +59,26 @@ export async function selectOption( await user.click(target); } +/** + * Pick an item from a bs-ui (Radix) DropdownMenu radio group — the items carry + * role="menuitemradio", not role="option", so `selectOption` cannot see them. + * + * Usage: + * await selectMenuOption('actionLevel.change.edit', 3); + */ +export async function selectMenuOption( + triggerLabel: string, + option: string | RegExp | number +) { + const user = userEvent.setup(); + await user.click(screen.getByLabelText(triggerLabel)); + const target = + typeof option === 'number' + ? (await screen.findAllByRole('menuitemradio'))[option] + : await screen.findByRole('menuitemradio', { name: option }); + await user.click(target); +} + // Re-export everything from @testing-library/react export * from '@testing-library/react'; // Override render with the custom version diff --git a/src/frontend/platform/src/types/global.d.ts b/src/frontend/platform/src/types/global.d.ts index 819b9dff9c..f9d978472f 100644 --- a/src/frontend/platform/src/types/global.d.ts +++ b/src/frontend/platform/src/types/global.d.ts @@ -58,3 +58,13 @@ declare module "*.svg" { const content: any; export default content; } + +// `silent` is read by the response interceptor in `@/controllers/request.ts`: +// it skips the global error handling and rejects with the response envelope +// instead of a bare message string, so a caller can read the business error's +// `data`. Declared here so passing it no longer needs an `as any` cast. +declare module "axios" { + export interface AxiosRequestConfig { + silent?: boolean + } +}