mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-09 07:20:44 +08:00
实现附件处理链路的统一引用化与可检索增强,避免大文件/多模态内容直接撑爆上下文并提升工具可用性。
本次补齐 text/image/video/archive 的标准化处理、会话级工具守卫、回放压缩、配置与文档对齐,并修复表格与流式输出相关体验问题。 Made-with: Cursor
This commit is contained in:
parent
9d2900db02
commit
37a2ef35f4
27 changed files with 3227 additions and 278 deletions
|
|
@ -40,7 +40,7 @@ def _oclaw_config_path() -> Path:
|
|||
p = Path(raw)
|
||||
return p if p.is_absolute() else p.resolve()
|
||||
# repo_root/src/admin/chat_api.py -> repo_root
|
||||
return Path(__file__).resolve().parents[2] / "oclaw" / "oclaw.json"
|
||||
return Path(__file__).resolve().parents[2] / "oclaw.json"
|
||||
|
||||
|
||||
def _wiki_root_from_config() -> Path | None:
|
||||
|
|
@ -103,6 +103,15 @@ _DISPATCH_REASON_LABELS_SETTING_KEY = "AIA_DISPATCH_REASON_LABELS_JSON"
|
|||
_SPECIALIST_FLAGS_SETTING_KEY = "AIA_CHAT_SPECIALIST_FLAGS_JSON"
|
||||
_CHAT_SPECIALIST_IDS: tuple[str, ...] = tuple(str(x) for x in SPECIALIST_IDS if str(x).strip())
|
||||
DEFAULT_TABULAR_SQL_TIMEOUT_MS = 8_000
|
||||
DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS = 5_000
|
||||
DEFAULT_TEXT_INLINE_MAX_CHARS = 12_000
|
||||
DEFAULT_TEXT_CHUNK_SIZE = 1_600
|
||||
DEFAULT_TEXT_CHUNK_OVERLAP = 200
|
||||
DEFAULT_TEXT_QUERY_TOP_K = 5
|
||||
DEFAULT_IMAGE_RESULT_REPLAY_CAP_CHARS = 4_000
|
||||
DEFAULT_VIDEO_RESULT_REPLAY_CAP_CHARS = 4_000
|
||||
DEFAULT_VIDEO_TRANSCRIPT_CHUNK_SIZE = 1_600
|
||||
DEFAULT_VIDEO_TRANSCRIPT_CHUNK_OVERLAP = 200
|
||||
|
||||
|
||||
def _safe_rel_avatar_name(name: str) -> str:
|
||||
|
|
@ -441,9 +450,31 @@ def _tabular_limits_from_oclaw_config() -> dict[str, int]:
|
|||
"max_excel_sheets": _safe_int(tabular.get("max_excel_sheets"), DEFAULT_MAX_EXCEL_SHEETS, max_value=500),
|
||||
"large_table_preview_rows": _safe_int(tabular.get("large_table_preview_rows"), 20, max_value=500),
|
||||
"tool_mode_enabled": bool(tabular.get("tool_mode_enabled", True)),
|
||||
"tool_mode_min_rows": _safe_int(tabular.get("tool_mode_min_rows"), 20_000),
|
||||
"tool_mode_min_rows": _safe_int(tabular.get("tool_mode_min_rows"), DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS),
|
||||
"tool_mode_max_bytes": _safe_int(tabular.get("tool_mode_max_bytes"), 30 * 1024 * 1024),
|
||||
"sql_timeout_ms": _safe_timeout_ms(tabular.get("sql_timeout_ms"), DEFAULT_TABULAR_SQL_TIMEOUT_MS),
|
||||
"text_inline_max_chars": _safe_int(tabular.get("text_inline_max_chars"), DEFAULT_TEXT_INLINE_MAX_CHARS, max_value=200_000),
|
||||
"text_chunk_size": _safe_int(tabular.get("text_chunk_size"), DEFAULT_TEXT_CHUNK_SIZE, max_value=8_000),
|
||||
"text_chunk_overlap": _safe_int(tabular.get("text_chunk_overlap"), DEFAULT_TEXT_CHUNK_OVERLAP, max_value=4_000),
|
||||
"text_query_top_k": _safe_int(tabular.get("text_query_top_k"), DEFAULT_TEXT_QUERY_TOP_K, max_value=50),
|
||||
"image_result_replay_cap_chars": _safe_int(
|
||||
tabular.get("image_result_replay_cap_chars"), DEFAULT_IMAGE_RESULT_REPLAY_CAP_CHARS, min_value=600, max_value=30_000
|
||||
),
|
||||
"video_result_replay_cap_chars": _safe_int(
|
||||
tabular.get("video_result_replay_cap_chars"), DEFAULT_VIDEO_RESULT_REPLAY_CAP_CHARS, min_value=600, max_value=30_000
|
||||
),
|
||||
"video_transcript_chunk_size": _safe_int(
|
||||
tabular.get("video_transcript_chunk_size"), DEFAULT_VIDEO_TRANSCRIPT_CHUNK_SIZE, max_value=8_000
|
||||
),
|
||||
"video_transcript_chunk_overlap": _safe_int(
|
||||
tabular.get("video_transcript_chunk_overlap"), DEFAULT_VIDEO_TRANSCRIPT_CHUNK_OVERLAP, max_value=4_000
|
||||
),
|
||||
"archive_max_depth": _safe_int(tabular.get("archive_max_depth"), 2, max_value=10),
|
||||
"archive_max_file_count": _safe_int(tabular.get("archive_max_file_count"), 200, max_value=20_000),
|
||||
"archive_max_entry_bytes": _safe_int(tabular.get("archive_max_entry_bytes"), 10 * 1024 * 1024, max_value=2_000_000_000),
|
||||
"archive_max_total_uncompressed_bytes": _safe_int(
|
||||
tabular.get("archive_max_total_uncompressed_bytes"), 50 * 1024 * 1024, max_value=5_000_000_000
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -470,11 +501,69 @@ def _set_tabular_limits_into_oclaw_config(limits: dict[str, int]) -> dict[str, i
|
|||
"max_excel_sheets": int(limits.get("max_excel_sheets") or DEFAULT_MAX_EXCEL_SHEETS),
|
||||
"large_table_preview_rows": int(limits.get("large_table_preview_rows") or 20),
|
||||
"tool_mode_enabled": bool(limits.get("tool_mode_enabled", True)),
|
||||
"tool_mode_min_rows": int(limits.get("tool_mode_min_rows") or 20_000),
|
||||
"tool_mode_min_rows": int(limits.get("tool_mode_min_rows") or DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS),
|
||||
"tool_mode_max_bytes": int(limits.get("tool_mode_max_bytes") or (30 * 1024 * 1024)),
|
||||
"sql_timeout_ms": int(
|
||||
_safe_timeout_ms(limits.get("sql_timeout_ms"), DEFAULT_TABULAR_SQL_TIMEOUT_MS)
|
||||
),
|
||||
"text_inline_max_chars": int(
|
||||
_safe_int(limits.get("text_inline_max_chars"), DEFAULT_TEXT_INLINE_MAX_CHARS, max_value=200_000)
|
||||
),
|
||||
"text_chunk_size": int(
|
||||
_safe_int(limits.get("text_chunk_size"), DEFAULT_TEXT_CHUNK_SIZE, max_value=8_000)
|
||||
),
|
||||
"text_chunk_overlap": int(
|
||||
_safe_int(limits.get("text_chunk_overlap"), DEFAULT_TEXT_CHUNK_OVERLAP, max_value=4_000)
|
||||
),
|
||||
"text_query_top_k": int(
|
||||
_safe_int(limits.get("text_query_top_k"), DEFAULT_TEXT_QUERY_TOP_K, max_value=50)
|
||||
),
|
||||
"image_result_replay_cap_chars": int(
|
||||
_safe_int(
|
||||
limits.get("image_result_replay_cap_chars"),
|
||||
DEFAULT_IMAGE_RESULT_REPLAY_CAP_CHARS,
|
||||
min_value=600,
|
||||
max_value=30_000,
|
||||
)
|
||||
),
|
||||
"video_result_replay_cap_chars": int(
|
||||
_safe_int(
|
||||
limits.get("video_result_replay_cap_chars"),
|
||||
DEFAULT_VIDEO_RESULT_REPLAY_CAP_CHARS,
|
||||
min_value=600,
|
||||
max_value=30_000,
|
||||
)
|
||||
),
|
||||
"video_transcript_chunk_size": int(
|
||||
_safe_int(
|
||||
limits.get("video_transcript_chunk_size"),
|
||||
DEFAULT_VIDEO_TRANSCRIPT_CHUNK_SIZE,
|
||||
max_value=8_000,
|
||||
)
|
||||
),
|
||||
"video_transcript_chunk_overlap": int(
|
||||
_safe_int(
|
||||
limits.get("video_transcript_chunk_overlap"),
|
||||
DEFAULT_VIDEO_TRANSCRIPT_CHUNK_OVERLAP,
|
||||
max_value=4_000,
|
||||
)
|
||||
),
|
||||
"archive_max_depth": int(_safe_int(limits.get("archive_max_depth"), 2, max_value=10)),
|
||||
"archive_max_file_count": int(
|
||||
_safe_int(limits.get("archive_max_file_count"), 200, max_value=20_000)
|
||||
),
|
||||
"archive_max_entry_bytes": int(
|
||||
_safe_int(
|
||||
limits.get("archive_max_entry_bytes"), 10 * 1024 * 1024, max_value=2_000_000_000
|
||||
)
|
||||
),
|
||||
"archive_max_total_uncompressed_bytes": int(
|
||||
_safe_int(
|
||||
limits.get("archive_max_total_uncompressed_bytes"),
|
||||
50 * 1024 * 1024,
|
||||
max_value=5_000_000_000,
|
||||
)
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -493,6 +582,72 @@ def _chat_session_mode_setting_key(*, tenant_id: str, user_id: str, session_id:
|
|||
return f"chat.session.mode.{tenant_id}.{user_id}.{session_id}.{field}"
|
||||
|
||||
|
||||
def _chat_user_mode_setting_key(*, tenant_id: str, user_id: str, field: str) -> str:
|
||||
return f"chat.user.mode.{tenant_id}.{user_id}.{field}"
|
||||
|
||||
|
||||
def _resolve_mode_settings(
|
||||
*,
|
||||
store: SqliteStore,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
) -> tuple[str, str, str]:
|
||||
"""Resolve chat mode with global-user preference first, then session fallback."""
|
||||
user_mode_key = _chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="interaction_mode")
|
||||
user_specialist_key = _chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="specialist")
|
||||
user_memory_mode_key = _chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="memory_mode")
|
||||
session_mode_key = _chat_session_mode_setting_key(
|
||||
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode"
|
||||
)
|
||||
session_specialist_key = _chat_session_mode_setting_key(
|
||||
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="specialist"
|
||||
)
|
||||
session_memory_mode_key = _chat_session_mode_setting_key(
|
||||
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode"
|
||||
)
|
||||
|
||||
mode_raw = str(store.get_setting(user_mode_key) or "").strip() or str(store.get_setting(session_mode_key) or "").strip()
|
||||
specialist_raw = str(store.get_setting(user_specialist_key) or "").strip() or str(
|
||||
store.get_setting(session_specialist_key) or ""
|
||||
).strip()
|
||||
memory_raw = str(store.get_setting(user_memory_mode_key) or "").strip() or str(store.get_setting(session_memory_mode_key) or "").strip()
|
||||
|
||||
interaction_mode = normalize_interaction_mode(mode_raw or "expert")
|
||||
specialist = normalize_requested_specialist(specialist_raw or "generalist")
|
||||
specialist = _apply_specialist_flags(store, specialist)
|
||||
memory_mode = _normalize_memory_mode({"memory_mode": (memory_raw or "default")})
|
||||
return interaction_mode, specialist, memory_mode
|
||||
|
||||
|
||||
def _persist_mode_settings(
|
||||
*,
|
||||
store: SqliteStore,
|
||||
tenant_id: str,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
interaction_mode: str,
|
||||
specialist: str,
|
||||
memory_mode: str,
|
||||
) -> None:
|
||||
"""Persist as global user default and current-session compatibility snapshot."""
|
||||
for key in (
|
||||
_chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="interaction_mode"),
|
||||
_chat_session_mode_setting_key(tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode"),
|
||||
):
|
||||
store.set_setting(key, interaction_mode)
|
||||
for key in (
|
||||
_chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="specialist"),
|
||||
_chat_session_mode_setting_key(tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="specialist"),
|
||||
):
|
||||
store.set_setting(key, specialist)
|
||||
for key in (
|
||||
_chat_user_mode_setting_key(tenant_id=tenant_id, user_id=user_id, field="memory_mode"),
|
||||
_chat_session_mode_setting_key(tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode"),
|
||||
):
|
||||
store.set_setting(key, memory_mode)
|
||||
|
||||
|
||||
def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStore, str | None], dict[str, Any]]) -> None:
|
||||
chat = APIRouter(prefix="/admin/api/chat", tags=["chat"])
|
||||
|
||||
|
|
@ -538,6 +693,21 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
user_id = str(ctx.get("user_id") or "")
|
||||
title = str(payload.get("title") or "").strip() or ("新会话" if _api_lang(store) == "zh" else "New Chat")
|
||||
s = store.create_session_for_user(title=title, tenant_id=tenant_id, user_id=user_id)
|
||||
interaction_mode, specialist, memory_mode = _resolve_mode_settings(
|
||||
store=store,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
session_id=str(s.id),
|
||||
)
|
||||
_persist_mode_settings(
|
||||
store=store,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
session_id=str(s.id),
|
||||
interaction_mode=interaction_mode,
|
||||
specialist=specialist,
|
||||
memory_mode=memory_mode,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"session": {
|
||||
|
|
@ -603,6 +773,21 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
title=("新会话" if lang == "zh" else "New Chat"), tenant_id=tenant_id, user_id=user_id
|
||||
)
|
||||
next_id = str(ns.id)
|
||||
interaction_mode, specialist, memory_mode = _resolve_mode_settings(
|
||||
store=store,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
session_id=next_id,
|
||||
)
|
||||
_persist_mode_settings(
|
||||
store=store,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
session_id=next_id,
|
||||
interaction_mode=interaction_mode,
|
||||
specialist=specialist,
|
||||
memory_mode=memory_mode,
|
||||
)
|
||||
return {"ok": True, "next_session_id": next_id}
|
||||
|
||||
@chat.post("/sessions/{session_id}/fork")
|
||||
|
|
@ -793,19 +978,12 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
sess = _resolve_chat_session(store, ctx, session_id)
|
||||
if not sess:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
mode_key = _chat_session_mode_setting_key(
|
||||
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode"
|
||||
interaction_mode, specialist, memory_mode = _resolve_mode_settings(
|
||||
store=store,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
session_id=str(session_id),
|
||||
)
|
||||
specialist_key = _chat_session_mode_setting_key(
|
||||
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="specialist"
|
||||
)
|
||||
memory_mode_key = _chat_session_mode_setting_key(
|
||||
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode"
|
||||
)
|
||||
interaction_mode = normalize_interaction_mode(store.get_setting(mode_key))
|
||||
specialist = normalize_requested_specialist(store.get_setting(specialist_key))
|
||||
specialist = _apply_specialist_flags(store, specialist)
|
||||
memory_mode = _normalize_memory_mode({"memory_mode": store.get_setting(memory_mode_key)})
|
||||
return {"ok": True, "interaction_mode": interaction_mode, "specialist": specialist, "memory_mode": memory_mode}
|
||||
|
||||
@chat.post("/sessions/{session_id}/mode")
|
||||
|
|
@ -825,19 +1003,16 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
interaction_mode = normalize_interaction_mode(payload.get("interaction_mode"))
|
||||
specialist = normalize_requested_specialist(payload.get("specialist"))
|
||||
specialist = _apply_specialist_flags(store, specialist)
|
||||
mode_key = _chat_session_mode_setting_key(
|
||||
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode"
|
||||
)
|
||||
specialist_key = _chat_session_mode_setting_key(
|
||||
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="specialist"
|
||||
)
|
||||
memory_mode_key = _chat_session_mode_setting_key(
|
||||
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode"
|
||||
)
|
||||
memory_mode = _normalize_memory_mode(payload)
|
||||
store.set_setting(mode_key, interaction_mode)
|
||||
store.set_setting(specialist_key, specialist)
|
||||
store.set_setting(memory_mode_key, memory_mode)
|
||||
_persist_mode_settings(
|
||||
store=store,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
session_id=str(session_id),
|
||||
interaction_mode=interaction_mode,
|
||||
specialist=specialist,
|
||||
memory_mode=memory_mode,
|
||||
)
|
||||
return {"ok": True, "interaction_mode": interaction_mode, "specialist": specialist, "memory_mode": memory_mode}
|
||||
|
||||
@chat.get("/admin/user-stats")
|
||||
|
|
@ -1066,9 +1241,21 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
"max_excel_sheets": DEFAULT_MAX_EXCEL_SHEETS,
|
||||
"large_table_preview_rows": 20,
|
||||
"tool_mode_enabled": True,
|
||||
"tool_mode_min_rows": 20_000,
|
||||
"tool_mode_min_rows": DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS,
|
||||
"tool_mode_max_bytes": 30 * 1024 * 1024,
|
||||
"sql_timeout_ms": DEFAULT_TABULAR_SQL_TIMEOUT_MS,
|
||||
"text_inline_max_chars": DEFAULT_TEXT_INLINE_MAX_CHARS,
|
||||
"text_chunk_size": DEFAULT_TEXT_CHUNK_SIZE,
|
||||
"text_chunk_overlap": DEFAULT_TEXT_CHUNK_OVERLAP,
|
||||
"text_query_top_k": DEFAULT_TEXT_QUERY_TOP_K,
|
||||
"image_result_replay_cap_chars": DEFAULT_IMAGE_RESULT_REPLAY_CAP_CHARS,
|
||||
"video_result_replay_cap_chars": DEFAULT_VIDEO_RESULT_REPLAY_CAP_CHARS,
|
||||
"video_transcript_chunk_size": DEFAULT_VIDEO_TRANSCRIPT_CHUNK_SIZE,
|
||||
"video_transcript_chunk_overlap": DEFAULT_VIDEO_TRANSCRIPT_CHUNK_OVERLAP,
|
||||
"archive_max_depth": 2,
|
||||
"archive_max_file_count": 200,
|
||||
"archive_max_entry_bytes": 10 * 1024 * 1024,
|
||||
"archive_max_total_uncompressed_bytes": 50 * 1024 * 1024,
|
||||
}
|
||||
saved = _set_tabular_limits_into_oclaw_config(limits)
|
||||
return {"ok": True, "cleared": True, "limits": saved}
|
||||
|
|
@ -1081,9 +1268,33 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
"max_excel_sheets": _safe_int(raw.get("max_excel_sheets"), DEFAULT_MAX_EXCEL_SHEETS, max_value=500),
|
||||
"large_table_preview_rows": _safe_int(raw.get("large_table_preview_rows"), 20, max_value=500),
|
||||
"tool_mode_enabled": bool(raw.get("tool_mode_enabled", True)),
|
||||
"tool_mode_min_rows": _safe_int(raw.get("tool_mode_min_rows"), 20_000),
|
||||
"tool_mode_min_rows": _safe_int(raw.get("tool_mode_min_rows"), DEFAULT_TABULAR_TOOL_MODE_MIN_ROWS),
|
||||
"tool_mode_max_bytes": _safe_int(raw.get("tool_mode_max_bytes"), 30 * 1024 * 1024),
|
||||
"sql_timeout_ms": _safe_timeout_ms(raw.get("sql_timeout_ms"), DEFAULT_TABULAR_SQL_TIMEOUT_MS),
|
||||
"text_inline_max_chars": _safe_int(raw.get("text_inline_max_chars"), DEFAULT_TEXT_INLINE_MAX_CHARS, max_value=200_000),
|
||||
"text_chunk_size": _safe_int(raw.get("text_chunk_size"), DEFAULT_TEXT_CHUNK_SIZE, max_value=8_000),
|
||||
"text_chunk_overlap": _safe_int(raw.get("text_chunk_overlap"), DEFAULT_TEXT_CHUNK_OVERLAP, max_value=4_000),
|
||||
"text_query_top_k": _safe_int(raw.get("text_query_top_k"), DEFAULT_TEXT_QUERY_TOP_K, max_value=50),
|
||||
"image_result_replay_cap_chars": _safe_int(
|
||||
raw.get("image_result_replay_cap_chars"), DEFAULT_IMAGE_RESULT_REPLAY_CAP_CHARS, min_value=600, max_value=30_000
|
||||
),
|
||||
"video_result_replay_cap_chars": _safe_int(
|
||||
raw.get("video_result_replay_cap_chars"), DEFAULT_VIDEO_RESULT_REPLAY_CAP_CHARS, min_value=600, max_value=30_000
|
||||
),
|
||||
"video_transcript_chunk_size": _safe_int(
|
||||
raw.get("video_transcript_chunk_size"), DEFAULT_VIDEO_TRANSCRIPT_CHUNK_SIZE, max_value=8_000
|
||||
),
|
||||
"video_transcript_chunk_overlap": _safe_int(
|
||||
raw.get("video_transcript_chunk_overlap"), DEFAULT_VIDEO_TRANSCRIPT_CHUNK_OVERLAP, max_value=4_000
|
||||
),
|
||||
"archive_max_depth": _safe_int(raw.get("archive_max_depth"), 2, max_value=10),
|
||||
"archive_max_file_count": _safe_int(raw.get("archive_max_file_count"), 200, max_value=20_000),
|
||||
"archive_max_entry_bytes": _safe_int(
|
||||
raw.get("archive_max_entry_bytes"), 10 * 1024 * 1024, max_value=2_000_000_000
|
||||
),
|
||||
"archive_max_total_uncompressed_bytes": _safe_int(
|
||||
raw.get("archive_max_total_uncompressed_bytes"), 50 * 1024 * 1024, max_value=5_000_000_000
|
||||
),
|
||||
}
|
||||
saved = _set_tabular_limits_into_oclaw_config(next_limits)
|
||||
return {"ok": True, "limits": saved}
|
||||
|
|
@ -1253,20 +1464,26 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
interaction_mode, selected_specialist = _normalize_chat_mode(payload)
|
||||
memory_mode = _normalize_memory_mode(payload)
|
||||
if "interaction_mode" not in payload and "chat_mode" not in payload:
|
||||
mode_key = _chat_session_mode_setting_key(
|
||||
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode"
|
||||
interaction_mode, _, _ = _resolve_mode_settings(
|
||||
store=store,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
session_id=str(session_id),
|
||||
)
|
||||
interaction_mode = normalize_interaction_mode(store.get_setting(mode_key))
|
||||
if "specialist" not in payload:
|
||||
specialist_key = _chat_session_mode_setting_key(
|
||||
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="specialist"
|
||||
_, selected_specialist, _ = _resolve_mode_settings(
|
||||
store=store,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
session_id=str(session_id),
|
||||
)
|
||||
selected_specialist = normalize_requested_specialist(store.get_setting(specialist_key))
|
||||
if "memory_mode" not in payload:
|
||||
memory_mode_key = _chat_session_mode_setting_key(
|
||||
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode"
|
||||
_, _, memory_mode = _resolve_mode_settings(
|
||||
store=store,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
session_id=str(session_id),
|
||||
)
|
||||
memory_mode = _normalize_memory_mode({"memory_mode": store.get_setting(memory_mode_key)})
|
||||
selected_specialist = _apply_specialist_flags(store, selected_specialist)
|
||||
if not text_raw and not attachments:
|
||||
raise HTTPException(status_code=400, detail="text_or_attachments_required")
|
||||
|
|
@ -1351,20 +1568,26 @@ def include_chat_routes(router: APIRouter, *, resolve_auth: Callable[[SqliteStor
|
|||
interaction_mode, selected_specialist = _normalize_chat_mode(payload)
|
||||
memory_mode = _normalize_memory_mode(payload)
|
||||
if "interaction_mode" not in payload and "chat_mode" not in payload:
|
||||
mode_key = _chat_session_mode_setting_key(
|
||||
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="interaction_mode"
|
||||
interaction_mode, _, _ = _resolve_mode_settings(
|
||||
store=store,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
session_id=str(session_id),
|
||||
)
|
||||
interaction_mode = normalize_interaction_mode(store.get_setting(mode_key))
|
||||
if "specialist" not in payload:
|
||||
specialist_key = _chat_session_mode_setting_key(
|
||||
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="specialist"
|
||||
_, selected_specialist, _ = _resolve_mode_settings(
|
||||
store=store,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
session_id=str(session_id),
|
||||
)
|
||||
selected_specialist = normalize_requested_specialist(store.get_setting(specialist_key))
|
||||
if "memory_mode" not in payload:
|
||||
memory_mode_key = _chat_session_mode_setting_key(
|
||||
tenant_id=tenant_id, user_id=user_id, session_id=str(session_id), field="memory_mode"
|
||||
_, _, memory_mode = _resolve_mode_settings(
|
||||
store=store,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
session_id=str(session_id),
|
||||
)
|
||||
memory_mode = _normalize_memory_mode({"memory_mode": store.get_setting(memory_mode_key)})
|
||||
selected_specialist = _apply_specialist_flags(store, selected_specialist)
|
||||
if not text_raw and not attachments:
|
||||
raise HTTPException(status_code=400, detail="text_or_attachments_required")
|
||||
|
|
|
|||
|
|
@ -45,9 +45,21 @@ const I18N = {
|
|||
"attachments.toolModeMinRows": "触发工具模式最小行数",
|
||||
"attachments.toolModeMaxBytes": "工具模式最大文件字节数",
|
||||
"attachments.sqlTimeoutMs": "SQL 超时(ms)",
|
||||
"attachments.imageReplayCapChars": "图片结果回放上限字符数",
|
||||
"attachments.videoReplayCapChars": "视频转写结果回放上限字符数",
|
||||
"attachments.videoTranscriptChunkSize": "视频转写分块大小",
|
||||
"attachments.videoTranscriptChunkOverlap": "视频转写分块重叠",
|
||||
"attachments.archiveMaxDepth": "压缩包最大嵌套深度",
|
||||
"attachments.archiveMaxFileCount": "压缩包最大文件数",
|
||||
"attachments.archiveMaxEntryBytes": "压缩包单文件最大解压字节",
|
||||
"attachments.archiveMaxTotalBytes": "压缩包总解压字节上限",
|
||||
"attachments.highPreviewWarn": "大表摘要预览行数超过 200,可能显著增加 token 消耗。确认继续?",
|
||||
"attachments.invalidNumber": "请输入有效正整数",
|
||||
"attachments.sqlTimeoutHint": "硬超时(wall-clock)。范围 100..120000,默认 8000。命中会返回 sql_timeout 结构化错误。",
|
||||
"attachments.imageReplayCapHint": "历史轮次中,query_image_attachment 的 OCR/描述结果回放上限。范围 600..30000,默认 4000。",
|
||||
"attachments.videoReplayCapHint": "历史轮次中,query_video_attachment 的 transcript 结果回放上限。范围 600..30000,默认 4000。",
|
||||
"attachments.videoTranscriptChunkHint": "query_video_attachment(task=transcript) 默认使用该分块参数落库,便于后续 query_text_attachment 检索。",
|
||||
"attachments.archivePolicyHint": "archive_processor 的统一预算:支持 zip/tar/tgz/gz,限制嵌套层级、文件数与解压体积。",
|
||||
"attachments.loadError": "加载失败",
|
||||
"attachments.saved": "已保存",
|
||||
"attachments.save": "保存",
|
||||
|
|
@ -452,9 +464,21 @@ const I18N = {
|
|||
"attachments.toolModeMinRows": "Min rows to trigger tool mode",
|
||||
"attachments.toolModeMaxBytes": "Max file bytes for tool mode",
|
||||
"attachments.sqlTimeoutMs": "SQL timeout (ms)",
|
||||
"attachments.imageReplayCapChars": "Image replay cap chars",
|
||||
"attachments.videoReplayCapChars": "Video replay cap chars",
|
||||
"attachments.videoTranscriptChunkSize": "Video transcript chunk size",
|
||||
"attachments.videoTranscriptChunkOverlap": "Video transcript chunk overlap",
|
||||
"attachments.archiveMaxDepth": "Archive max depth",
|
||||
"attachments.archiveMaxFileCount": "Archive max file count",
|
||||
"attachments.archiveMaxEntryBytes": "Archive max entry uncompressed bytes",
|
||||
"attachments.archiveMaxTotalBytes": "Archive max total uncompressed bytes",
|
||||
"attachments.highPreviewWarn": "Large-table preview rows is above 200, which may significantly increase token usage. Continue?",
|
||||
"attachments.invalidNumber": "Please enter valid positive integers",
|
||||
"attachments.sqlTimeoutHint": "Hard wall-clock timeout. Range 100..120000, default 8000. Timeout returns structured sql_timeout error.",
|
||||
"attachments.imageReplayCapHint": "Replay cap for query_image_attachment OCR/description results in historical context. Range 600..30000, default 4000.",
|
||||
"attachments.videoReplayCapHint": "Replay cap for query_video_attachment transcript results in historical context. Range 600..30000, default 4000.",
|
||||
"attachments.videoTranscriptChunkHint": "Default chunk parameters used by query_video_attachment(task=transcript) when persisting transcript chunks for query_text_attachment retrieval.",
|
||||
"attachments.archivePolicyHint": "Unified archive_processor budget for zip/tar/tgz/gz: limits depth, file count and uncompressed size.",
|
||||
"attachments.loadError": "Load failed",
|
||||
"attachments.saved": "Saved",
|
||||
"attachments.save": "Save",
|
||||
|
|
@ -6399,6 +6423,14 @@ async function renderAttachments() {
|
|||
const toolMinRowsInput = el("input", { class: "input", type: "number", min: "1", step: "1" });
|
||||
const toolMaxBytesInput = el("input", { class: "input", type: "number", min: "1", step: "1" });
|
||||
const sqlTimeoutInput = el("input", { class: "input", type: "number", min: "100", max: "120000", step: "1" });
|
||||
const imageReplayCapInput = el("input", { class: "input", type: "number", min: "600", max: "30000", step: "1" });
|
||||
const videoReplayCapInput = el("input", { class: "input", type: "number", min: "600", max: "30000", step: "1" });
|
||||
const videoTranscriptChunkSizeInput = el("input", { class: "input", type: "number", min: "1", max: "8000", step: "1" });
|
||||
const videoTranscriptChunkOverlapInput = el("input", { class: "input", type: "number", min: "1", max: "4000", step: "1" });
|
||||
const archiveMaxDepthInput = el("input", { class: "input", type: "number", min: "1", max: "10", step: "1" });
|
||||
const archiveMaxFileCountInput = el("input", { class: "input", type: "number", min: "1", max: "20000", step: "1" });
|
||||
const archiveMaxEntryBytesInput = el("input", { class: "input", type: "number", min: "1", step: "1" });
|
||||
const archiveMaxTotalBytesInput = el("input", { class: "input", type: "number", min: "1", step: "1" });
|
||||
|
||||
const clampTimeout = (raw, fallback = 8000) => {
|
||||
const n = parseInt(String(raw ?? "").trim(), 10);
|
||||
|
|
@ -6421,6 +6453,14 @@ async function renderAttachments() {
|
|||
const minRows = parsePositiveInt(toolMinRowsInput.value);
|
||||
const maxBytes = parsePositiveInt(toolMaxBytesInput.value);
|
||||
const sqlTimeoutMs = parsePositiveInt(sqlTimeoutInput.value);
|
||||
const imageReplayCapChars = parsePositiveInt(imageReplayCapInput.value);
|
||||
const videoReplayCapChars = parsePositiveInt(videoReplayCapInput.value);
|
||||
const videoTranscriptChunkSize = parsePositiveInt(videoTranscriptChunkSizeInput.value);
|
||||
const videoTranscriptChunkOverlap = parsePositiveInt(videoTranscriptChunkOverlapInput.value);
|
||||
const archiveMaxDepth = parsePositiveInt(archiveMaxDepthInput.value);
|
||||
const archiveMaxFileCount = parsePositiveInt(archiveMaxFileCountInput.value);
|
||||
const archiveMaxEntryBytes = parsePositiveInt(archiveMaxEntryBytesInput.value);
|
||||
const archiveMaxTotalBytes = parsePositiveInt(archiveMaxTotalBytesInput.value);
|
||||
if (
|
||||
rows === null ||
|
||||
cols === null ||
|
||||
|
|
@ -6429,7 +6469,15 @@ async function renderAttachments() {
|
|||
previewRows === null ||
|
||||
minRows === null ||
|
||||
maxBytes === null ||
|
||||
sqlTimeoutMs === null
|
||||
sqlTimeoutMs === null ||
|
||||
imageReplayCapChars === null ||
|
||||
videoReplayCapChars === null ||
|
||||
videoTranscriptChunkSize === null ||
|
||||
videoTranscriptChunkOverlap === null ||
|
||||
archiveMaxDepth === null ||
|
||||
archiveMaxFileCount === null ||
|
||||
archiveMaxEntryBytes === null ||
|
||||
archiveMaxTotalBytes === null
|
||||
) {
|
||||
throw new Error(t("attachments.invalidNumber"));
|
||||
}
|
||||
|
|
@ -6446,6 +6494,14 @@ async function renderAttachments() {
|
|||
tool_mode_min_rows: minRows,
|
||||
tool_mode_max_bytes: maxBytes,
|
||||
sql_timeout_ms: clampTimeout(sqlTimeoutMs, 8000),
|
||||
image_result_replay_cap_chars: Math.max(600, Math.min(30000, imageReplayCapChars)),
|
||||
video_result_replay_cap_chars: Math.max(600, Math.min(30000, videoReplayCapChars)),
|
||||
video_transcript_chunk_size: Math.max(1, Math.min(8000, videoTranscriptChunkSize)),
|
||||
video_transcript_chunk_overlap: Math.max(1, Math.min(4000, videoTranscriptChunkOverlap)),
|
||||
archive_max_depth: Math.max(1, Math.min(10, archiveMaxDepth)),
|
||||
archive_max_file_count: Math.max(1, Math.min(20000, archiveMaxFileCount)),
|
||||
archive_max_entry_bytes: Math.max(1, archiveMaxEntryBytes),
|
||||
archive_max_total_uncompressed_bytes: Math.max(1, archiveMaxTotalBytes),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -6457,9 +6513,17 @@ async function renderAttachments() {
|
|||
maxSheetsInput.value = String(l.max_excel_sheets || 50);
|
||||
largePreviewRowsInput.value = String(l.large_table_preview_rows || 20);
|
||||
toolEnabledInput.checked = !!l.tool_mode_enabled;
|
||||
toolMinRowsInput.value = String(l.tool_mode_min_rows || 20000);
|
||||
toolMinRowsInput.value = String(l.tool_mode_min_rows || 5000);
|
||||
toolMaxBytesInput.value = String(l.tool_mode_max_bytes || 31457280);
|
||||
sqlTimeoutInput.value = String(clampTimeout(l.sql_timeout_ms, 8000));
|
||||
imageReplayCapInput.value = String(Math.max(600, Math.min(30000, parsePositiveInt(l.image_result_replay_cap_chars) || 4000)));
|
||||
videoReplayCapInput.value = String(Math.max(600, Math.min(30000, parsePositiveInt(l.video_result_replay_cap_chars) || 4000)));
|
||||
videoTranscriptChunkSizeInput.value = String(Math.max(1, Math.min(8000, parsePositiveInt(l.video_transcript_chunk_size) || 1600)));
|
||||
videoTranscriptChunkOverlapInput.value = String(Math.max(1, Math.min(4000, parsePositiveInt(l.video_transcript_chunk_overlap) || 200)));
|
||||
archiveMaxDepthInput.value = String(Math.max(1, Math.min(10, parsePositiveInt(l.archive_max_depth) || 2)));
|
||||
archiveMaxFileCountInput.value = String(Math.max(1, Math.min(20000, parsePositiveInt(l.archive_max_file_count) || 200)));
|
||||
archiveMaxEntryBytesInput.value = String(Math.max(1, parsePositiveInt(l.archive_max_entry_bytes) || 10485760));
|
||||
archiveMaxTotalBytesInput.value = String(Math.max(1, parsePositiveInt(l.archive_max_total_uncompressed_bytes) || 52428800));
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
|
|
@ -6538,6 +6602,18 @@ async function renderAttachments() {
|
|||
inputRow(t("attachments.toolModeMaxBytes"), toolMaxBytesInput),
|
||||
inputRow(t("attachments.sqlTimeoutMs"), sqlTimeoutInput),
|
||||
el("div", { class: "muted", style: "margin-top:6px;line-height:1.45;", text: t("attachments.sqlTimeoutHint") }),
|
||||
inputRow(t("attachments.imageReplayCapChars"), imageReplayCapInput),
|
||||
el("div", { class: "muted", style: "margin-top:6px;line-height:1.45;", text: t("attachments.imageReplayCapHint") }),
|
||||
inputRow(t("attachments.videoReplayCapChars"), videoReplayCapInput),
|
||||
el("div", { class: "muted", style: "margin-top:6px;line-height:1.45;", text: t("attachments.videoReplayCapHint") }),
|
||||
inputRow(t("attachments.videoTranscriptChunkSize"), videoTranscriptChunkSizeInput),
|
||||
inputRow(t("attachments.videoTranscriptChunkOverlap"), videoTranscriptChunkOverlapInput),
|
||||
el("div", { class: "muted", style: "margin-top:6px;line-height:1.45;", text: t("attachments.videoTranscriptChunkHint") }),
|
||||
inputRow(t("attachments.archiveMaxDepth"), archiveMaxDepthInput),
|
||||
inputRow(t("attachments.archiveMaxFileCount"), archiveMaxFileCountInput),
|
||||
inputRow(t("attachments.archiveMaxEntryBytes"), archiveMaxEntryBytesInput),
|
||||
inputRow(t("attachments.archiveMaxTotalBytes"), archiveMaxTotalBytesInput),
|
||||
el("div", { class: "muted", style: "margin-top:6px;line-height:1.45;", text: t("attachments.archivePolicyHint") }),
|
||||
el("div", { class: "row", style: "gap:8px;margin-top:12px;flex-wrap:wrap;" }, [loadBtn, resetBtn, saveBtn]),
|
||||
el("div", { style: "height:8px" }),
|
||||
status,
|
||||
|
|
|
|||
|
|
@ -288,8 +288,14 @@ const CHAT_URL_SCOPE_KEY = "ops_chat_url_scope";
|
|||
const CHAT_SPECIALIST_PREF_KEY = "ops_chat_specialist_pref";
|
||||
const CHAT_INTERACTION_MODE_KEY = "ops_chat_interaction_mode";
|
||||
const CHAT_MEMORY_MODE_KEY = "ops_chat_memory_mode";
|
||||
const CHAT_REASONING_TOGGLE_KEY = "ops_chat_reasoning_toggle";
|
||||
const ADMIN_CHAT_SHOW_TOOL_OUTPUT_DEFAULT = false;
|
||||
let adminChatShowToolOutput = ADMIN_CHAT_SHOW_TOOL_OUTPUT_DEFAULT;
|
||||
const REASONING_BLOCK_MAX_CHARS = 12000;
|
||||
const CHAT_ENABLE_WIKI_EVENT_POLLER = false;
|
||||
const _rt = String(localStorage.getItem(CHAT_REASONING_TOGGLE_KEY) || "").trim().toLowerCase();
|
||||
let adminChatShowToolOutput = _rt
|
||||
? ["1", "true", "yes", "on"].includes(_rt)
|
||||
: ADMIN_CHAT_SHOW_TOOL_OUTPUT_DEFAULT;
|
||||
let authSession = null;
|
||||
|
||||
function _toolSummaryTitle(role) {
|
||||
|
|
@ -303,10 +309,33 @@ function _normalizeEventType(v) {
|
|||
}
|
||||
|
||||
function _collapsedBlockNode(title, text) {
|
||||
const raw = String(text || "");
|
||||
const clipped = raw.length > REASONING_BLOCK_MAX_CHARS ? raw.slice(0, REASONING_BLOCK_MAX_CHARS) : raw;
|
||||
const suffix =
|
||||
raw.length > REASONING_BLOCK_MAX_CHARS
|
||||
? `\n\n[truncated ${raw.length - REASONING_BLOCK_MAX_CHARS} chars to keep UI responsive]`
|
||||
: "";
|
||||
const box = document.createElement("div");
|
||||
box.className = "chat-msg__reasoning-block";
|
||||
box.appendChild(el("div", { class: "chat-msg__reasoning-title", text: String(title || "") }));
|
||||
box.appendChild(el("pre", { class: "chat-msg__reasoning-pre", text: String(text || "") || "—" }));
|
||||
const pre = el("pre", { class: "chat-msg__reasoning-pre", text: (clipped + suffix) || "—" });
|
||||
box.appendChild(pre);
|
||||
if (raw.length > REASONING_BLOCK_MAX_CHARS) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "btn btn-sm";
|
||||
btn.style.marginTop = "6px";
|
||||
btn.textContent = currentLang === "zh" ? "展开全文(可能较慢)" : "Load full text (may be slow)";
|
||||
btn.addEventListener("click", () => {
|
||||
btn.disabled = true;
|
||||
btn.textContent = currentLang === "zh" ? "加载中..." : "Loading...";
|
||||
requestAnimationFrame(() => {
|
||||
pre.textContent = raw || "—";
|
||||
btn.remove();
|
||||
});
|
||||
});
|
||||
box.appendChild(btn);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
|
|
@ -1206,12 +1235,14 @@ function createStreamStitcher() {
|
|||
};
|
||||
}
|
||||
|
||||
function formatToolPanelText(name, payload) {
|
||||
function formatToolPanelText(name, payload, options = {}) {
|
||||
const streamMode = !!(options && options.streamMode);
|
||||
const truncateToolPanel = (s) => {
|
||||
const raw = String(s || "").trim();
|
||||
const maxChars = 50;
|
||||
if (streamMode) return raw;
|
||||
const maxChars = 4000;
|
||||
if (raw.length <= maxChars) return raw;
|
||||
return `${raw.slice(0, maxChars)}…(详情请重新阅读)`;
|
||||
return `${raw.slice(0, maxChars)}\n\n…(为保证界面性能,已截断 ${raw.length - maxChars} 字符)`;
|
||||
};
|
||||
const n = String(name || "").trim() || "tool";
|
||||
const p = payload && typeof payload === "object" ? payload : {};
|
||||
|
|
@ -1252,7 +1283,7 @@ function formatToolPanelText(name, payload) {
|
|||
body = String(r || "");
|
||||
}
|
||||
}
|
||||
body = normalizeStreamText(body).replace(/\\n/g, "").replace(/\n/g, "");
|
||||
body = normalizeStreamText(body);
|
||||
return `${n}\n${truncateToolPanel(body)}`.trim();
|
||||
}
|
||||
|
||||
|
|
@ -1870,12 +1901,12 @@ async function renderChatUi() {
|
|||
});
|
||||
modeSelect.appendChild(el("option", { value: "comprehensive", text: t("chat.modeComprehensive") }));
|
||||
modeSelect.appendChild(el("option", { value: "expert", text: t("chat.modeExpert") }));
|
||||
const _im = String(localStorage.getItem(CHAT_INTERACTION_MODE_KEY) || "comprehensive").toLowerCase();
|
||||
modeSelect.value = ["comprehensive", "expert"].includes(_im) ? _im : "comprehensive";
|
||||
const _im = String(localStorage.getItem(CHAT_INTERACTION_MODE_KEY) || "expert").toLowerCase();
|
||||
modeSelect.value = ["comprehensive", "expert"].includes(_im) ? _im : "expert";
|
||||
const _mm = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase();
|
||||
localStorage.setItem(CHAT_MEMORY_MODE_KEY, _mm === "store_only" ? "store_only" : "default");
|
||||
modeSelect.addEventListener("change", () => {
|
||||
localStorage.setItem(CHAT_INTERACTION_MODE_KEY, String(modeSelect.value || "comprehensive"));
|
||||
localStorage.setItem(CHAT_INTERACTION_MODE_KEY, String(modeSelect.value || "expert"));
|
||||
saveSessionModePreference();
|
||||
});
|
||||
const applySpecialistOptions = () => {
|
||||
|
|
@ -1944,7 +1975,7 @@ async function renderChatUi() {
|
|||
if (String(modeSelect.value || "").toLowerCase() !== "expert") {
|
||||
specialistSelect.value = "generalist";
|
||||
}
|
||||
localStorage.setItem(CHAT_INTERACTION_MODE_KEY, String(modeSelect.value || "comprehensive"));
|
||||
localStorage.setItem(CHAT_INTERACTION_MODE_KEY, String(modeSelect.value || "expert"));
|
||||
localStorage.setItem(CHAT_SPECIALIST_PREF_KEY, String(specialistSelect.value || "generalist"));
|
||||
const mml = String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default").toLowerCase();
|
||||
localStorage.setItem(CHAT_MEMORY_MODE_KEY, mml === "store_only" ? "store_only" : "default");
|
||||
|
|
@ -1955,9 +1986,9 @@ async function renderChatUi() {
|
|||
if (!activeId) return;
|
||||
try {
|
||||
await apiPost(`/admin/api/chat/sessions/${encodeURIComponent(activeId)}/mode`, {
|
||||
interaction_mode: String(modeSelect.value || "comprehensive"),
|
||||
interaction_mode: String(modeSelect.value || "expert"),
|
||||
specialist:
|
||||
String(modeSelect.value || "comprehensive").toLowerCase() === "expert"
|
||||
String(modeSelect.value || "expert").toLowerCase() === "expert"
|
||||
? String(specialistSelect.value || "generalist")
|
||||
: "generalist",
|
||||
memory_mode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"),
|
||||
|
|
@ -1975,7 +2006,7 @@ async function renderChatUi() {
|
|||
]);
|
||||
|
||||
const syncModeSpecialistUi = () => {
|
||||
const m = String(modeSelect.value || "comprehensive").toLowerCase();
|
||||
const m = String(modeSelect.value || "expert").toLowerCase();
|
||||
const showSpecialist = m === "expert";
|
||||
specialistLabelEl.style.display = showSpecialist ? "" : "none";
|
||||
specialistSelect.style.display = showSpecialist ? "" : "none";
|
||||
|
|
@ -2069,22 +2100,12 @@ async function renderChatUi() {
|
|||
topTopic = String(k || "");
|
||||
}
|
||||
}
|
||||
const actions = [
|
||||
{
|
||||
label: t("chat.wikiViewMerged"),
|
||||
onClick: () => openWikiPreviewModal({ sessionId: String(activeId || ""), path: "inbox/merged-turns.md" }),
|
||||
},
|
||||
];
|
||||
if (topTopic) {
|
||||
actions.push({
|
||||
label: t("chat.wikiViewTopic", { topic: topTopic }),
|
||||
onClick: () => openWikiPreviewModal({ sessionId: String(activeId || ""), path: `topics/auto-${topTopic}.md` }),
|
||||
});
|
||||
}
|
||||
const actions = [];
|
||||
return { kind: "info", text: t("chat.wikiToastMerged", { merged: String(merged), skipped: String(skipped) }), actions };
|
||||
};
|
||||
|
||||
const pollWikiEvents = async () => {
|
||||
if (!CHAT_ENABLE_WIKI_EVENT_POLLER) return;
|
||||
const sid = String(activeId || "");
|
||||
if (!sid) return;
|
||||
const after = String((wikiAfterFinishedAt && wikiAfterFinishedAt[sid]) || "");
|
||||
|
|
@ -2129,6 +2150,7 @@ async function renderChatUi() {
|
|||
};
|
||||
|
||||
const startWikiPoller = () => {
|
||||
if (!CHAT_ENABLE_WIKI_EVENT_POLLER) return;
|
||||
if (wikiPollTimerId != null) return;
|
||||
wikiPollTimerId = setInterval(pollWikiEvents, 2200);
|
||||
// kick once
|
||||
|
|
@ -2171,6 +2193,7 @@ async function renderChatUi() {
|
|||
toolToggleBtn.addEventListener("click", () => {
|
||||
showToolOutput = !showToolOutput;
|
||||
adminChatShowToolOutput = showToolOutput;
|
||||
localStorage.setItem(CHAT_REASONING_TOGGLE_KEY, showToolOutput ? "1" : "0");
|
||||
syncToolToggleBtn();
|
||||
const isStreaming = composerShell.classList.contains("chat-composer-shell--busy");
|
||||
if (isStreaming) {
|
||||
|
|
@ -2464,6 +2487,7 @@ async function renderChatUi() {
|
|||
statusBar.textContent = t("chat.loading");
|
||||
const resp = await apiPost("/admin/api/chat/sessions", {});
|
||||
adoptCreatedSession(resp);
|
||||
await loadSessionModePreference();
|
||||
paintSessions();
|
||||
messagesEl.innerHTML = "";
|
||||
messagesEl.appendChild(el("div", { class: "muted", text: t("chat.empty") }));
|
||||
|
|
@ -2614,7 +2638,7 @@ async function renderChatUi() {
|
|||
attachments: Array.isArray(attachments) ? attachments : [],
|
||||
idempotencyKey: String(idempotencyKey || `idem_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`),
|
||||
thinking: "default",
|
||||
interaction_mode: String(interactionMode || "comprehensive"),
|
||||
interaction_mode: String(interactionMode || "expert"),
|
||||
specialist: String(specialist || "generalist"),
|
||||
memory_mode: String(memoryMode || "default"),
|
||||
},
|
||||
|
|
@ -2710,7 +2734,7 @@ async function renderChatUi() {
|
|||
let currentStreamAbortController = null;
|
||||
let currentWsTransport = null;
|
||||
let currentAbortMeta = { sessionId: "", runId: "" };
|
||||
const WS_CHAT_SEND_TIMEOUT_MS = 60000;
|
||||
const WS_CHAT_SEND_TIMEOUT_MS = 180000;
|
||||
const isAbortError = (err) => {
|
||||
const name = String(err && err.name ? err.name : "");
|
||||
const msg = String(err && err.message ? err.message : err || "");
|
||||
|
|
@ -2856,10 +2880,12 @@ async function renderChatUi() {
|
|||
const chunks = [];
|
||||
for (const seg of chatStreamSegments) {
|
||||
if (!seg || seg.type !== "text") continue;
|
||||
const txt = String(seg.text || "").trim();
|
||||
const txt = String(seg.text || "");
|
||||
if (txt) chunks.push(txt);
|
||||
}
|
||||
return decodeEscapedNewlines(chunks.join("\n"));
|
||||
// Keep 1:1 character mapping with original text segments.
|
||||
// Never inject separators here; otherwise typewriter offsets drift.
|
||||
return decodeEscapedNewlines(chunks.join(""));
|
||||
};
|
||||
const _renderStreamCompositeNow = () => {
|
||||
const bubble = ensureStreamBubble();
|
||||
|
|
@ -2902,7 +2928,7 @@ async function renderChatUi() {
|
|||
const showLen = Math.max(0, Math.min(full.length, streamDisplayShown.length - textIdx));
|
||||
const shown = full.slice(0, showLen);
|
||||
textIdx += full.length;
|
||||
if (!shown.trim()) continue;
|
||||
if (!shown) continue;
|
||||
blocks.push(`<div class="chat-msg__plain">${escapeHtml(decodeEscapedNewlines(shown))}</div>`);
|
||||
} else if (seg.type === "tool") {
|
||||
if (!showToolOutput) continue;
|
||||
|
|
@ -2968,8 +2994,9 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
renderStreamComposite();
|
||||
return;
|
||||
}
|
||||
// Slightly faster typing while keeping typewriter feel for正文.
|
||||
const step = 2;
|
||||
// Adaptive typing speed: when backlog grows, catch up quickly
|
||||
// to avoid fragmented/laggy streaming and end-of-turn text bursts.
|
||||
const step = Math.max(4, Math.min(180, Math.ceil(remain / 10)));
|
||||
streamDisplayShown = streamDisplayTarget.slice(0, streamDisplayShown.length + step);
|
||||
renderStreamComposite();
|
||||
if (typingTimerId != null) {
|
||||
|
|
@ -2979,7 +3006,7 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
typingTimerId = setTimeout(() => {
|
||||
typingTimerId = null;
|
||||
_scheduleTypingTick();
|
||||
}, 22);
|
||||
}, 14);
|
||||
});
|
||||
};
|
||||
const setStreamText = (text) => {
|
||||
|
|
@ -2996,11 +3023,12 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
const appendToolSegment = (payload) => {
|
||||
const p = payload && typeof payload === "object" ? payload : {};
|
||||
const name = String(p.name || "tool");
|
||||
const liveTag = currentLang === "zh" ? "[实时全量]" : "[LIVE FULL]";
|
||||
const key = `${name}:${++toolSeq}`;
|
||||
const rawPayload = p.payload != null ? p.payload : p;
|
||||
const body = formatToolPanelText(name, rawPayload);
|
||||
const body = formatToolPanelText(name, rawPayload, { streamMode: true });
|
||||
const sqlAudit = extractSqlAuditPayload(rawPayload);
|
||||
chatStreamSegments.push({ type: "tool", key, title: name, body, sqlAudit });
|
||||
chatStreamSegments.push({ type: "tool", key, title: `${name} ${liveTag}`, body, sqlAudit });
|
||||
};
|
||||
const appendFinalAssistant = async (message, fallbackText) => {
|
||||
const normalized = _normalizeAssistantMessage(message, { requireRole: false, requireContentArray: false });
|
||||
|
|
@ -3053,10 +3081,10 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
sessionId: activeId,
|
||||
text: userText,
|
||||
attachments: attachmentPayload,
|
||||
interactionMode: String(modeSelect.value || "comprehensive"),
|
||||
interactionMode: String(modeSelect.value || "expert"),
|
||||
idempotencyKey: String(turnId || ""),
|
||||
specialist:
|
||||
String(modeSelect.value || "comprehensive").toLowerCase() === "expert"
|
||||
String(modeSelect.value || "expert").toLowerCase() === "expert"
|
||||
? String(specialistSelect.value || "generalist")
|
||||
: "generalist",
|
||||
memoryMode: String(localStorage.getItem(CHAT_MEMORY_MODE_KEY) || "default"),
|
||||
|
|
@ -3144,6 +3172,11 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
chatStream = "";
|
||||
chatRunId = null;
|
||||
chatStreamSegments = [];
|
||||
// Final assistant body may arrive before reasoning/tool rows are fully persisted.
|
||||
// Reload once shortly after final to avoid "reasoning disappears until session switch".
|
||||
setTimeout(() => {
|
||||
loadMessagesForActive().catch(() => {});
|
||||
}, 350);
|
||||
if (!ok) statusBar.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
|
@ -3209,6 +3242,24 @@ ${autoLimit ? `<div style="margin-top:8px;"><span class="muted">auto-added claus
|
|||
}, 350);
|
||||
return doneMeta;
|
||||
}
|
||||
// WS timeout may happen while backend still computes and persists final reply.
|
||||
// Try recovering from persisted history before surfacing a hard error.
|
||||
if (emsg.includes("ws_send_timeout")) {
|
||||
try {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
if (await _recoverLatestAssistantFromHistory()) {
|
||||
statusBar.textContent =
|
||||
currentLang === "zh"
|
||||
? "请求超时,已从历史补齐本轮结果。"
|
||||
: "Request timed out; recovered this turn from history.";
|
||||
return doneMeta || { __transport: "ws_timeout_history_recovered" };
|
||||
}
|
||||
await _sleep(600 + i * 400);
|
||||
}
|
||||
} catch (_) {
|
||||
// fall through to generic ws-like failure recovery
|
||||
}
|
||||
}
|
||||
// WS may time out/close while backend persists final message slightly later.
|
||||
// Poll history briefly before surfacing hard failure.
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue