From e2a962de32a0ae278790c52db28d0217a1fbb374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Wed, 3 Jun 2026 18:57:49 +0800 Subject: [PATCH] chore: update ji local changes --- .../components/workspace/recent-chat-list.tsx | 4 +- frontend-web/src/core/auth/api.ts | 42 +++++++++-- frontend-web/src/core/threads/utils.ts | 19 ++++- frontend-web/src/pages/ChatsPage.tsx | 2 +- offline-backend-20260512/backend/CLAUDE.md | 4 +- .../agents/middlewares/title_middleware.py | 56 ++++++++++++-- .../harness/deerflow/agents/thread_state.py | 4 + .../deerflow/runtime/scheduler/html_page.py | 73 ++++++++++++++++--- .../deerflow/runtime/scheduler/service.py | 19 +++-- 9 files changed, 185 insertions(+), 38 deletions(-) diff --git a/frontend-web/src/components/workspace/recent-chat-list.tsx b/frontend-web/src/components/workspace/recent-chat-list.tsx index e496da2c..7def3260 100644 --- a/frontend-web/src/components/workspace/recent-chat-list.tsx +++ b/frontend-web/src/components/workspace/recent-chat-list.tsx @@ -213,7 +213,7 @@ export function RecentChatList() { className="block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden" href={pathOfThread(thread)} > - {desensitize(titleOfThread(thread))} + {desensitize(titleOfThread(thread, t.pages.newChat))} {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && ( @@ -236,7 +236,7 @@ export function RecentChatList() { onSelect={() => handleRenameClick( thread.thread_id, - titleOfThread(thread), + titleOfThread(thread, t.pages.newChat), ) } > diff --git a/frontend-web/src/core/auth/api.ts b/frontend-web/src/core/auth/api.ts index f919a3dc..ba6258ff 100644 --- a/frontend-web/src/core/auth/api.ts +++ b/frontend-web/src/core/auth/api.ts @@ -64,24 +64,50 @@ export async function loginByUsername( return response.json() as Promise; } +// The upstream token issuer and the verifier (token_info_url) run on separate +// servers whose clocks can drift ~3-4s apart. A token verified right after it +// is issued can look "not yet valid / expired" to the verifier, which surfaces +// as a 401 here. That window closes on its own once real time advances past the +// skew, so we silently retry the exchange a couple of times before giving up — +// the LoginPage keeps showing its loading state, so the user never notices. +const TOKEN_LOGIN_MAX_ATTEMPTS = 3; // initial try + 2 retries → covers up to ~6s skew +const TOKEN_LOGIN_RETRY_DELAY_MS = 3000; + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + export async function loginByToken( token: string, ): Promise { // Token-exchange login: the gateway resolves the upstream token to a username // and signs the matching local account in. - const response = await apiFetch( - `${getBackendBaseURL()}/api/v1/auth/login/token?token=${encodeURIComponent(token)}`, - { + const url = `${getBackendBaseURL()}/api/v1/auth/login/token?token=${encodeURIComponent(token)}`; + let lastError = "Login failed."; + + for (let attempt = 1; attempt <= TOKEN_LOGIN_MAX_ATTEMPTS; attempt += 1) { + const response = await apiFetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, - }, - ); - if (!response.ok) { + }); + if (response.ok) { + return response.json() as Promise; + } + const error = await response.json().catch(() => null); - throw new Error(extractErrorMessage(error)); + lastError = extractErrorMessage(error); + + // Only 401 is plausibly the transient clock-skew case and worth waiting on. + // 403 (disabled), 429 (rate-limited), 500 (misconfigured) won't self-heal, + // so fail fast instead of stalling the user. + const isTransient = response.status === 401; + if (!isTransient || attempt === TOKEN_LOGIN_MAX_ATTEMPTS) { + throw new Error(lastError); + } + await wait(TOKEN_LOGIN_RETRY_DELAY_MS); } - return response.json() as Promise; + + // Unreachable: the loop either returns or throws above. + throw new Error(lastError); } diff --git a/frontend-web/src/core/threads/utils.ts b/frontend-web/src/core/threads/utils.ts index 8877eda3..91270dfb 100644 --- a/frontend-web/src/core/threads/utils.ts +++ b/frontend-web/src/core/threads/utils.ts @@ -62,6 +62,21 @@ export function textOfMessage(message: Message) { return null; } -export function titleOfThread(thread: AgentThread) { - return thread.values?.title ?? "Untitled"; +export function titleOfThread(thread: AgentThread, fallback = "Untitled") { + const title = thread.values?.title; + if (title) return title; + + // No title yet (e.g. the very first turn is still streaming, or the backend + // title pass hasn't finished). Derive a sensible display title from the first + // user message so the user never sees a bare "Untitled" placeholder. + const messages = thread.values?.messages; + if (Array.isArray(messages)) { + const firstHuman = messages.find((m) => m.type === "human"); + const text = firstHuman ? textOfMessage(firstHuman)?.trim() : null; + if (text) { + return text.length > 50 ? `${text.slice(0, 50).trimEnd()}…` : text; + } + } + + return fallback; } diff --git a/frontend-web/src/pages/ChatsPage.tsx b/frontend-web/src/pages/ChatsPage.tsx index a6885c1b..d4ba237c 100644 --- a/frontend-web/src/pages/ChatsPage.tsx +++ b/frontend-web/src/pages/ChatsPage.tsx @@ -71,7 +71,7 @@ export default function ChatsPage() {
-
{desensitize(titleOfThread(thread))}
+
{desensitize(titleOfThread(thread, t.pages.newChat))}
{thread.updated_at && (
diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index d01901f2..e4b56a9b 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -166,7 +166,7 @@ Lead-agent middlewares are assembled in strict append order across `packages/har 9. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled) 10. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode) 11. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional) -12. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model +12. **TitleMiddleware** - Two-phase auto-title (never shows "untitled"): `before_model` sets an instant question-derived *provisional* title the moment the first user message arrives (no LLM, zero latency), then `after_model` asynchronously generates the final LLM title and replaces it (tracked via the `title_provisional` state flag). Normalizes structured message content before prompting the title model 13. **MemoryMiddleware** - Memory V2: `wrap_model_call` injects Hindsight recall transiently; `after_agent` auto-retains the turn to Hindsight 14. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support) 15. **DeferredToolFilterMiddleware** - Hides deferred tool schemas from the bound model until tool search is enabled (optional) @@ -178,7 +178,7 @@ Lead-agent middlewares are assembled in strict append order across `packages/har 10. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled) 11. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode) 12. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional) -13. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model +13. **TitleMiddleware** - Two-phase auto-title (never shows "untitled"): `before_model` sets an instant question-derived *provisional* title the moment the first user message arrives (no LLM, zero latency), then `after_model` asynchronously generates the final LLM title and replaces it (tracked via the `title_provisional` state flag). Normalizes structured message content before prompting the title model 14. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses) 15. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support) 16. **DeferredToolFilterMiddleware** - Hides deferred tool schemas from the bound model until tool search is enabled (optional) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py index 0e658b49..f45d4b0c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py @@ -23,6 +23,7 @@ class TitleMiddlewareState(AgentState): """Compatible with the `ThreadState` schema.""" title: NotRequired[str | None] + title_provisional: NotRequired[bool] class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): @@ -61,16 +62,37 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): return "" - def _should_generate_title(self, state: TitleMiddlewareState) -> bool: - """Check if we should generate a title for this thread.""" + def _should_set_provisional_title(self, state: TitleMiddlewareState) -> bool: + """Check if we should set an instant, question-derived placeholder title. + + Runs in ``before_model`` so the thread shows a sensible title the moment + the user sends their first message — eliminating the "untitled" window + while the (possibly long) first answer streams. No LLM call, no latency. + """ config = self._get_title_config() if not config.enabled: return False - # Check if thread already has a title in state + # Already has a title (provisional or final) — nothing to do. if state.get("title"): return False + # Only on the very first turn, as soon as the user message arrives. + messages = state.get("messages", []) + user_messages = [m for m in messages if m.type == "human"] + return len(user_messages) == 1 + + def _should_generate_title(self, state: TitleMiddlewareState) -> bool: + """Check if we should generate the final LLM title for this thread.""" + config = self._get_title_config() + if not config.enabled: + return False + + # Skip only if we already have a *finalized* title. A provisional + # placeholder set in before_model should still be upgraded by the LLM. + if state.get("title") and not state.get("title_provisional"): + return False + # Check if this is the first turn (has at least one user message and one assistant response) messages = state.get("messages", []) if len(messages) < 2: @@ -146,13 +168,27 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): config["tags"] = [*(config.get("tags") or []), "middleware:title"] return config + def _provisional_title_result(self, state: TitleMiddlewareState) -> dict | None: + """Set an instant placeholder title from the first user message (no LLM).""" + if not self._should_set_provisional_title(state): + return None + + messages = state.get("messages", []) + user_msg_content = next((m.content for m in messages if m.type == "human"), "") + user_msg = self._normalize_content(user_msg_content) + if not user_msg.strip(): + return None + title = self._fallback_title(user_msg) + logger.debug("TitleMiddleware: set provisional title %r before first model call", title) + return {"title": title, "title_provisional": True} + def _generate_title_result(self, state: TitleMiddlewareState) -> dict | None: """Generate a local fallback title without blocking on an LLM call.""" if not self._should_generate_title(state): return None _, user_msg = self._build_title_prompt(state) - return {"title": self._fallback_title(user_msg)} + return {"title": self._fallback_title(user_msg), "title_provisional": False} def _force_disable_thinking(self, model: object) -> None: """Force-disable thinking on the title model via `extra_body` overrides. @@ -204,10 +240,18 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]): response = await model.ainvoke(prompt, config=self._get_runnable_config()) title = self._parse_title(response.content) if title: - return {"title": title} + return {"title": title, "title_provisional": False} except Exception: logger.debug("Failed to generate async title; falling back to local title", exc_info=True) - return {"title": self._fallback_title(user_msg)} + return {"title": self._fallback_title(user_msg), "title_provisional": False} + + @override + def before_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None: + return self._provisional_title_result(state) + + @override + async def abefore_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None: + return self._provisional_title_result(state) @override def after_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py index c4bec378..cdd3f95d 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py @@ -69,6 +69,10 @@ class ThreadState(AgentState): sandbox: NotRequired[SandboxState | None] thread_data: NotRequired[ThreadDataState | None] title: NotRequired[str | None] + # True while `title` is a provisional, question-derived placeholder that the + # async LLM pass has not yet finalized. Lets TitleMiddleware show a title + # instantly (no "untitled" window) and upgrade it once the answer is ready. + title_provisional: NotRequired[bool] artifacts: Annotated[list[str], merge_artifacts] todos: NotRequired[list | None] uploaded_files: NotRequired[list[dict] | None] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/html_page.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/html_page.py index a9c5c698..cf38335d 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/html_page.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/html_page.py @@ -128,6 +128,45 @@ def extract_json_object(text: str | None) -> dict[str, Any] | None: return None +def recover_html_document(text: str | None) -> str | None: + """Slice a complete HTML document out of a model reply. + + Used as a fallback when the JSON wrapper around the ``html`` field can't be + parsed — the model commonly emits *literal* newlines and *unescaped* quotes + (``"zh-CN"``) inside the value, which is invalid JSON, so + :func:`extract_json_object` returns ``None``. Without this, the caller would + archive the whole ``{"title": ..., "html": "…"}`` blob as the + page. We locate the ```` / ```` start and the final + ```` and return just that span, dropping the surrounding JSON + scaffolding. Also accepts a reply that is already bare HTML. Returns + ``None`` when no HTML document can be found. + """ + if not text or not isinstance(text, str): + return None + lowered = text.lower() + start = lowered.find("") + candidate = text[start:] if end == -1 else text[start : end + len("")] + candidate = candidate.strip() + # If the html had been a properly-escaped JSON string value (the JSON failed + # to parse for some *other* reason), unescape the common sequences so they + # don't survive into the rendered page. Only triggers when escapes exist, so + # genuinely-literal HTML (the usual broken-JSON case) is left untouched. + if "\\n" in candidate or '\\"' in candidate or "\\/" in candidate or "\\t" in candidate: + candidate = ( + candidate.replace('\\"', '"') + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t") + .replace("\\/", "/") + ) + return candidate or None + + def sanitize_html(html: str) -> tuple[str, list[str]]: """Remove script-execution vectors from ``html``. @@ -242,11 +281,7 @@ _GENERATION_TEMPLATE = """你是一个内网页面生成助手。请根据输入 用户页面要求: {page_prompt} - -限制条件: -{negative_prompt} - -本次数据: +{constraint_block}本次数据: {data} 参考页面风格: @@ -258,11 +293,12 @@ _GENERATION_TEMPLATE = """你是一个内网页面生成助手。请根据输入 要求: 1. 只输出 JSON,不要输出 Markdown,不要输出多余解释。 2. JSON 字段必须包含 title、html、style_summary、layout_summary。 -3. html 必须是完整 HTML 文档,包含 、html、head、body。 -4. 所有 CSS 写在