chore: update ji local changes
This commit is contained in:
1 parent
f590f672be
commit
e2a962de32
9 files changed
+185
-38
No files matched your search
@@ -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))}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{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),
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -64,24 +64,50 @@ export async function loginByUsername(
|
||||
return response.json() as Promise<LoginByUsernameResponse>;
|
||||
}
|
||||
|
||||
// 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<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export async function loginByToken(
|
||||
token: string,
|
||||
): Promise<LoginByUsernameResponse> {
|
||||
// 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<LoginByUsernameResponse>;
|
||||
}
|
||||
|
||||
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<LoginByUsernameResponse>;
|
||||
|
||||
// Unreachable: the loop either returns or throws above.
|
||||
throw new Error(lastError);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export default function ChatsPage() {
|
||||
<Link key={thread.thread_id} href={pathOfThread(thread)}>
|
||||
<div className="flex flex-col gap-2 border-b p-4">
|
||||
<div>
|
||||
<div>{desensitize(titleOfThread(thread))}</div>
|
||||
<div>{desensitize(titleOfThread(thread, t.pages.newChat))}</div>
|
||||
</div>
|
||||
{thread.updated_at && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -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)
|
||||
|
||||
+50
-6
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
+61
-12
@@ -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": "<!doctype html>…"}`` blob as the
|
||||
page. We locate the ``<!doctype html>`` / ``<html …>`` start and the final
|
||||
``</html>`` 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("<!doctype html")
|
||||
if start == -1:
|
||||
start = lowered.find("<html")
|
||||
if start == -1:
|
||||
return None
|
||||
end = lowered.rfind("</html>")
|
||||
candidate = text[start:] if end == -1 else text[start : end + len("</html>")]
|
||||
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 文档,包含 <!doctype html>、html、head、body。
|
||||
4. 所有 CSS 写在 <style> 标签内,不要引用外部 CDN 或外链样式表。
|
||||
5. 不要包含 <script>、外链脚本、iframe、表单提交、on* 事件属性。
|
||||
6. 参考页面只用于风格和布局,页面内容必须来自“本次数据”,不要复用参考页旧数据。
|
||||
7. 页面使用中文,排版适合在浏览器中直接预览。
|
||||
3. html 字段的值必须是合法 JSON 字符串:其中所有换行必须写成 \\n、所有双引号必须转义为 \\",不要在字符串里直接换行或使用未转义的引号。
|
||||
4. html 必须是完整 HTML 文档,包含 <!doctype html>、html、head、body。
|
||||
5. 所有 CSS 写在 <style> 标签内,不要引用外部 CDN 或外链样式表。
|
||||
6. 不要包含 <script>、外链脚本、iframe、表单提交、on* 事件属性。
|
||||
7. 参考页面只用于风格和布局,页面内容必须来自“本次数据”,不要复用参考页旧数据。
|
||||
8. 页面使用中文,排版适合在浏览器中直接预览。
|
||||
"""
|
||||
|
||||
_REPAIR_TEMPLATE = """你是一个 HTML 修复助手。下面的 HTML 存在问题,请修复后重新输出完整页面。
|
||||
@@ -312,14 +348,27 @@ def build_generation_prompt(
|
||||
reference_layout: str | None,
|
||||
skill_instructions: str | None = None,
|
||||
data_limit: int = 16000,
|
||||
skill_limit: int = 16000,
|
||||
skill_limit: int = 30000,
|
||||
) -> str:
|
||||
skill_block = ""
|
||||
if skill_instructions and skill_instructions.strip():
|
||||
skill_block = "\n可用页面生成技能规范(请严格遵循):\n" + _clip(skill_instructions.strip(), skill_limit) + "\n"
|
||||
# The constraint block is only emitted when the user actually supplied one,
|
||||
# and is framed as a top-priority hard rule that overrides the page
|
||||
# requirements / reference page / data on conflict — otherwise the model
|
||||
# tends to treat "限制条件" as a soft hint and ignore it.
|
||||
constraint_block = ""
|
||||
neg = (negative_prompt or "").strip()
|
||||
if neg:
|
||||
constraint_block = (
|
||||
"\n限制条件(最高优先级的硬性约束,必须逐条严格遵守;"
|
||||
"若与上面的页面要求、参考页面或本次数据发生冲突,一律以这里的限制条件为准):\n"
|
||||
+ neg
|
||||
+ "\n\n"
|
||||
)
|
||||
return _GENERATION_TEMPLATE.format(
|
||||
page_prompt=(page_prompt or "(未提供,使用通用看板风格)").strip(),
|
||||
negative_prompt=(negative_prompt or "无").strip(),
|
||||
constraint_block=constraint_block,
|
||||
data=_clip(data, data_limit),
|
||||
reference_style=(reference_style or "无").strip(),
|
||||
reference_layout=(reference_layout or "无").strip(),
|
||||
|
||||
+14
-5
@@ -966,6 +966,7 @@ class ScheduledTaskService:
|
||||
"""
|
||||
cfg = self._html_config(task)
|
||||
page_prompt = str(cfg.get("page_prompt") or "").strip()
|
||||
negative_prompt = str(cfg.get("negative_prompt") or "").strip()
|
||||
gather_instruction = (
|
||||
"请运用你自身的检索/搜索等技能,围绕下述页面需求获取最新、真实的资料,"
|
||||
"并整理成结构化的中文要点(可用列表/小标题),供后续生成 HTML 页面使用,本步骤不要生成 HTML:\n"
|
||||
@@ -976,6 +977,11 @@ class ScheduledTaskService:
|
||||
base_prompt = str(task.get("prompt") or "").strip()
|
||||
if base_prompt:
|
||||
parts.append(f"补充说明:{base_prompt}")
|
||||
# Apply the user's constraints to data gathering too, not just rendering
|
||||
# — a "限制条件" like "只要最近 24 小时的新闻" or "排除某主题" must shape
|
||||
# which sources/data the agent collects, otherwise it silently leaks in.
|
||||
if negative_prompt:
|
||||
parts.append(f"限制条件(最高优先级,必须严格遵守):{negative_prompt}")
|
||||
gather_task = {**task, "prompt": "\n".join(parts)}
|
||||
# Never apply the markdown gate to the gather pass.
|
||||
gather_ctx = dict(task.get("execution_context") or {})
|
||||
@@ -1100,11 +1106,14 @@ class ScheduledTaskService:
|
||||
title = str(parsed.get("title") or task.get("name") or "生成页面").strip()
|
||||
html = parsed.get("html")
|
||||
if not isinstance(html, str) or "<" not in html:
|
||||
# GLM5 may have replied with bare HTML instead of JSON — accept it.
|
||||
if isinstance(raw, str) and ("<html" in raw.lower() or "<!doctype" in raw.lower()):
|
||||
html = raw
|
||||
else:
|
||||
raise ValueError("GLM5 未返回有效的 HTML(JSON 解析失败)")
|
||||
# The JSON wrapper couldn't be parsed (the model emitted literal
|
||||
# newlines / unescaped quotes inside the "html" value), or it replied
|
||||
# with bare HTML. Either way, recover just the HTML document so we
|
||||
# never archive the {"title":...,"html":"..."} JSON blob as the page.
|
||||
recovered = html_page_utils.recover_html_document(raw)
|
||||
if recovered is None:
|
||||
raise ValueError("模型未返回有效的 HTML(JSON 解析失败)")
|
||||
html = recovered
|
||||
style_summary = str(parsed.get("style_summary") or "").strip() or None
|
||||
layout_summary = str(parsed.get("layout_summary") or "").strip() or None
|
||||
|
||||
|
||||
Reference in new issue
Block a user