diff --git a/frontend-web/.env.development b/frontend-web/.env.development index 1d8b3666..0e89436f 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -26,7 +26,7 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn # 我的笔记前端 -VITE_AIPI_FRONTEND_URL= xxx +VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer # MaxKey 鉴权(getTokenByOnlineTicket) VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn diff --git a/frontend-web/.env.production b/frontend-web/.env.production index 67dabce1..6aa2d33d 100644 --- a/frontend-web/.env.production +++ b/frontend-web/.env.production @@ -25,6 +25,7 @@ VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn +VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn # 可选:Canvas 独立 LangGraph 地址 # VITE_CANVAS_LANGGRAPH_BASE_URL= diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index 2b108e3e..01800c52 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -400,6 +400,47 @@ export function ArtifactFileDetail({ } }, [displayContent, filepath]); + const handleOpenWriteFileInNewTab = useCallback(() => { + const source = displayContent ?? ""; + if (!source.trim()) { + toast.error("文件内容为空,暂时无法打开"); + return; + } + const blobType = + language === "html" + ? "text/html;charset=utf-8" + : language === "markdown" + ? "text/markdown;charset=utf-8" + : "text/plain;charset=utf-8"; + const blob = new Blob([source], { type: blobType }); + const url = URL.createObjectURL(blob); + window.open(url, "_blank", "noopener,noreferrer"); + window.setTimeout(() => URL.revokeObjectURL(url), 60_000); + }, [displayContent, language]); + + const handleDownloadWriteFile = useCallback(() => { + const source = displayContent ?? ""; + if (!source.trim()) { + toast.error("文件内容为空,暂时无法下载"); + return; + } + const blobType = + language === "html" + ? "text/html;charset=utf-8" + : language === "markdown" + ? "text/markdown;charset=utf-8" + : "text/plain;charset=utf-8"; + const blob = new Blob([source], { type: blobType }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = getFileName(filepath); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, [displayContent, filepath, language]); + return ( @@ -500,30 +541,32 @@ export function ArtifactFileDetail({ /> )} - {!isWriteFile && ( - { - try { - await openArtifactInNewTab({ - filepath, - threadId, - isMock, - publicShareCode, - }); - } catch (error) { - console.error(error); - toast.error( - error instanceof Error - ? error.message - : "Failed to open artifact", - ); - } - }} - /> - )} + { + if (isWriteFile) { + handleOpenWriteFileInNewTab(); + return; + } + try { + await openArtifactInNewTab({ + filepath, + threadId, + isMock, + publicShareCode, + }); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error + ? error.message + : "Failed to open artifact", + ); + } + }} + /> {isCodeFile && ( )} - {!isWriteFile && ( - { - try { - await downloadArtifactFile({ - filepath, - threadId, - isMock, - publicShareCode, - }); - } catch (error) { - console.error(error); - toast.error( - error instanceof Error - ? error.message - : "Download failed", - ); - } - }} - /> - )} + { + if (isWriteFile) { + handleDownloadWriteFile(); + return; + } + try { + await downloadArtifactFile({ + filepath, + threadId, + isMock, + publicShareCode, + }); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error + ? error.message + : "Download failed", + ); + } + }} + /> 0 ? (
- {allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"} + {allowMultiple + ? "可多选(再次点击取消选择)" + : multiPending + ? "请选择一项" + : "请选择一项(点击即发送)"}
{options.map((option) => { const optionId = option.id || option.label; @@ -956,7 +965,7 @@ function ClarificationCard({ @@ -1001,10 +1010,10 @@ function ClarificationCard({
) : null} - {isPending && (allowMultiple || payload.allow_custom !== false) ? ( + {isPending && (allowMultiple || multiPending || payload.allow_custom !== false) ? ( isLastPending ? (
- {multiPending && allowMultiple ? ( + {multiPending ? ( 将一起提交以上 {pendingCount} 个问题的回答 @@ -1016,10 +1025,10 @@ function ClarificationCard({ onClick={() => void onSubmitAll()} > - {submitting ? "发送中…" : multiPending && allowMultiple ? "全部发送" : "发送回答"} + {submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
- ) : allowMultiple ? ( + ) : allowMultiple || multiPending ? (
选择后等待最后一题统一提交
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 cf25b7b7..6148a239 100644 --- a/frontend-web/src/core/threads/utils.ts +++ b/frontend-web/src/core/threads/utils.ts @@ -62,8 +62,23 @@ 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; } export function sanitizeThreadTitleForDisplay(title: string | null | undefined) { diff --git a/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx b/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx index c64b18a8..be865ea4 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx @@ -77,16 +77,24 @@ export function WritingSetupChat() { setThreadPrepared(true); }, [threadId, threadPrepared]); + // 生成中点提交按钮 = 暂停:取消当前 SSE 流(与主聊天页一致)。 const handleChatSubmit = useCallback( async (text: string) => { + if (thread.isLoading) { + void thread.stop(); + return; + } const trimmed = text.trim(); - if (!trimmed || thread.isLoading) return; + if (!trimmed) return; await ensureThreadExists(); await sendMessage(threadId, { text: trimmed, files: [] }); }, - [ensureThreadExists, sendMessage, thread.isLoading, threadId], + [thread, ensureThreadExists, sendMessage, threadId], ); + // 提交按钮状态:生成中显示「停止」方块,可点击暂停对话 + const submitStatus = thread.error ? 'error' : thread.isLoading ? 'streaming' : 'ready'; + const handleApprove = useCallback( async (payload: SetupWritingApprovalPayload) => { applyProposal(payload); @@ -150,10 +158,9 @@ export function WritingSetupChat() { void handleChatSubmit(text)}> - + 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/lead_agent/agent.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py index d3f3e48c..ae1398fe 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -377,7 +377,7 @@ def make_lead_agent(config: RunnableConfig): def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): # Lazy import to avoid circular dependency from deerflow.tools import get_available_tools - from deerflow.tools.builtins import setup_agent, setup_writing + from deerflow.tools.builtins import ask_clarification_tool, setup_agent, setup_writing cfg = _get_runtime_config(config) resolved_app_config = app_config @@ -464,16 +464,16 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig): ) if is_writing_setup: - # 写作配置 Q&A:最小化 agent —— **只挂 setup_writing 一个工具** + 一份职责单一的独立提示。 + # 写作配置 Q&A:最小化 agent —— 只挂 setup_writing(提交配置)+ ask_clarification(弹澄清卡)。 # 关键:绝不给全套工具、绝不复用 lead 超级体提示,否则模型会把「新能源汽车市场分析」之类 # 长得像任务的输入直接做掉,而不是转成写作配置提交。 - # 末尾追加 WritingSetupRoundCapMiddleware:澄清最多 3 轮,到顶强制提交配置卡。 + # 末尾追加 WritingSetupRoundCapMiddleware:澄清最多 3 轮,到顶用 tool_choice 强制提交配置卡。 from deerflow.agents.lead_agent.prompt import build_writing_setup_prompt from deerflow.agents.middlewares.writing_setup_round_cap_middleware import WritingSetupRoundCapMiddleware return create_agent( model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config), - tools=[setup_writing], + tools=[setup_writing, ask_clarification_tool], middleware=[ *_build_middlewares(config, model_name=model_name, app_config=resolved_app_config), WritingSetupRoundCapMiddleware(max_rounds=WRITING_SETUP_MAX_ROUNDS), diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 914d30f7..cea2a124 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -924,11 +924,11 @@ def build_writing_setup_prompt(article_types: list[dict] | None = None) -> str: 【铁律——最高优先级】 - 你**绝对不撰写**文章正文、分析内容、大纲内容或任何成稿文字。即使用户的输入看起来就是一个写作任务(例如「新能源汽车市场分析」「帮我写一篇……」),你也**只把它当作"要配置的写作目标"**,绝不替用户动笔、绝不直接完成这篇写作。 -- 你能做的动作只有两个:① 用自然语言向用户提问或确认;② 调用 `setup_writing` 工具。**除此之外你没有任何其它工具**,也不要假装调用别的工具、不要做检索、不要做研究。 +- 你只有两个工具:① `ask_clarification`(向用户提问澄清,需要时用);② `setup_writing`(提交写作配置)。**没有任何其它工具**,不要做检索、不要做研究、不要假装调用别的工具。 - 不要在回复里输出文章段落、要点清单或大纲(用户主动粘贴的大纲除外——原样放进 `outline` 参数)。 【工作流程】(与用户交互确认,但最多 3 轮内必须提交) -1. 读用户输入,先用一两句话回应并和用户**确认写作意图**;就缺失或模糊的关键项(文章类型、目标字数、目标读者)提问澄清,一次最多问 1-2 个最关键的问题。 +1. 读用户输入。当关键项(文章类型、目标字数、目标读者)缺失或模糊时,调用 `ask_clarification` 向用户提问——**优先给出 `options` 选项**让用户点选(例如目标字数给「800 / 1500 / 3000」,读者给「普通读者 / 行业专家 / 政策决策者」)。一次可就 1-2 个独立的关键项各发一个 ask_clarification。 2. 与用户的澄清来回**最多 2 次**。一旦关键信息明确、或已经问过 2 次,就**立即进入第 3 步提交**,缺失字段用合理默认值,绝不无休止追问。 3. 调用 `setup_writing` 提交配置: - `user_intent`:精炼成一句话的写作目标(必填)。 @@ -936,7 +936,7 @@ def build_writing_setup_prompt(article_types: list[dict] | None = None) -> str: - `word_count_target`:正整数;用户没明说就给合理默认(如 800)。 - `target_audience`:如「普通读者」「行业专家」;没提就填「普通读者」。 - `writing_mode`:用户强调「严格依据素材/不要编造」→ `strict`,否则 `loose`。 - - `material_source`:用户提到用「技能/技能检索」→ `skill`,否则 `knowledge_base`。 + - `material_source`:**默认 `knowledge_base`(知识库)**;只有当用户明确要求用「技能/技能检索」时才填 `skill`,否则一律 `knowledge_base`。 - `outline`:用户提供的大纲原文(保留其层级与标题),否则留空。 4. 调用 `setup_writing` 前,用**一句话**说明你将提交的配置要点;调用之后不要再输出任何文章内容。 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/setup_agent_approval_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/setup_agent_approval_middleware.py index 3010aade..28ee54e3 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/setup_agent_approval_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/setup_agent_approval_middleware.py @@ -67,6 +67,10 @@ class SetupAgentApprovalMiddleware(AgentMiddleware[SetupAgentApprovalMiddlewareS args = setup_call.get("args") or {} tool_call_id = setup_call.get("id") or "" + + if isinstance(args.get("skills"), list) and len(args["skills"]) > 5: + args = {**args, "skills": args["skills"][:5]} + payload = self._build_payload(args) logger.info("Setup agent approval requested before writing agent files") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/setup_writing_approval_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/setup_writing_approval_middleware.py index 78ff2973..d1dbf0d8 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/setup_writing_approval_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/setup_writing_approval_middleware.py @@ -67,13 +67,17 @@ class SetupWritingApprovalMiddleware(AgentMiddleware[SetupWritingApprovalMiddlew return None def _build_payload(self, args: dict[str, Any]) -> dict[str, Any]: + # 素材来源:仅当模型明确给出 "skill" 时才用技能检索;用户没明确要求就一律默认知识库。 + material_source = ( + "skill" if str(args.get("material_source") or "").strip() == "skill" else "knowledge_base" + ) return { "user_intent": str(args.get("user_intent") or "").strip(), "article_type": str(args.get("article_type") or "").strip(), "word_count_target": self._coerce_word_count(args.get("word_count_target")), "target_audience": str(args.get("target_audience") or "").strip(), "writing_mode": str(args.get("writing_mode") or "").strip(), - "material_source": str(args.get("material_source") or "").strip(), + "material_source": material_source, "outline": str(args.get("outline") or "").strip(), } 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 写在