update preview text add process

This commit is contained in:
qnsh
2026-08-29 13:28:44 +08:00
parent cf7f922003
commit ef4d310e17
3 changed files with 337 additions and 78 deletions
+46 -6
View File
@@ -48,6 +48,7 @@ class _TextStreamSink:
self.client_id = getattr(server, "client_id", None)
self.seq = 0
self.last_activity = None
self.current_round = 0
def _send(self, phase, **extra):
PromptServer.instance.send_sync(
@@ -71,10 +72,45 @@ class _TextStreamSink:
if value:
self._send("delta", delta=value)
def activity(self, kind):
if kind and kind != self.last_activity:
self.last_activity = kind
self._send("activity", activity=kind)
def activity(self, kind, detail=None):
marker = (kind, detail or "")
if kind and marker != self.last_activity:
self.last_activity = marker
extra = {"activity": kind}
if detail:
extra["detail"] = detail
self._send("activity", **extra)
def round_start(self, round_index, tools_enabled=True):
self.current_round = int(round_index)
self._send("round_start", round=int(round_index), tools_enabled=bool(tools_enabled))
def candidate_delta(self, value, round_index=None):
if value:
extra = {"delta": value}
extra["round"] = int(self.current_round if round_index is None else round_index)
self._send("candidate_delta", **extra)
def tool_call_start(self, call_id, name, path=None, round_index=None):
extra = {"call_id": call_id, "tool": name}
if path:
extra["path"] = path
extra["round"] = int(self.current_round if round_index is None else round_index)
self._send("tool_call_start", **extra)
def tool_call_end(self, call_id, name, status="success", path=None, round_index=None):
extra = {"call_id": call_id, "tool": name, "status": status}
if path:
extra["path"] = path
extra["round"] = int(self.current_round if round_index is None else round_index)
self._send("tool_call_end", **extra)
def round_end(self, round_index, has_tool_calls):
self._send(
"round_end",
round=int(round_index),
has_tool_calls=bool(has_tool_calls),
)
def end(self, value):
self._send("end", text=value)
@@ -147,7 +183,7 @@ class OpenAITextAPI(io.ComfyNode):
io.Boolean.Input(id="clear_history", default=False),
io.Boolean.Input(
id="stream",
default=False,
default=True,
tooltip=(
"If true, the model response is streamed to the client as it is "
"generated using server-sent events (SSE)."
@@ -357,8 +393,12 @@ class OpenAITextAPI(io.ComfyNode):
persist_context=persist_context,
stream=stream_enabled,
post_stream=post_openai_sse_events,
on_delta=sink.delta if sink is not None else None,
on_delta=sink.candidate_delta if sink is not None else None,
on_activity=sink.activity if sink is not None else None,
on_round_start=sink.round_start if sink is not None else None,
on_round_end=sink.round_end if sink is not None else None,
on_tool_call_start=sink.tool_call_start if sink is not None else None,
on_tool_call_end=sink.tool_call_end if sink is not None else None,
)
except Exception as exc:
if sink is not None:
+118 -49
View File
@@ -406,6 +406,7 @@ def load_skill(snapshot):
LOAD_SKILL_TOOL = "load_skill"
READ_SKILL_FILE_TOOL = "read_skill_file"
READ_TOOL = "read"
INVOCATION_POLICY = "required_once_per_session"
EXECUTION_MODE = "pi_skill_agent"
@@ -478,14 +479,30 @@ def _function_definition(name):
"additionalProperties": False,
},
}
if name == READ_TOOL:
definition = _function_definition(READ_SKILL_FILE_TOOL)
definition["name"] = READ_TOOL
definition["description"] = (
"Read a text resource from the loaded Skill manifest. "
"Use offset (1-based line) and limit for large files."
)
definition["parameters"]["properties"].update({
"offset": {
"type": "integer", "minimum": 1,
"description": "1-based line number to start reading from.",
},
"limit": {
"type": "integer", "minimum": 1, "maximum": 2000,
"description": "Maximum number of lines to return.",
},
})
return definition
raise ValueError(f"Unknown Skill tool definition: {name}")
def skill_tool_definitions(protocol="openai-completions", loaded=False):
def skill_tool_definitions(protocol="openai-completions", loaded=False, read_tool=READ_SKILL_FILE_TOOL):
"""Return the provider schema for the only tool registered in this phase."""
definition = _function_definition(
READ_SKILL_FILE_TOOL if loaded else LOAD_SKILL_TOOL
)
definition = _function_definition((read_tool if loaded else LOAD_SKILL_TOOL))
if protocol == "openai-completions":
return [{"type": "function", "function": definition}]
if protocol == "openai-responses":
@@ -692,26 +709,42 @@ class ReadOnlySkillRuntime:
self.loaded = True
return output
if name != READ_SKILL_FILE_TOOL:
if name not in {READ_SKILL_FILE_TOOL, READ_TOOL}:
raise SkillExecutionError(
"pi_skill_tool_call_invalid", f"Unknown Skill tool: {name}"
)
if not self.loaded:
raise SkillExecutionError(
"skill_not_loaded", "read_skill_file requires load_skill first"
"skill_not_loaded", "read requires load_skill first"
)
if set(arguments) != {"path"} or not isinstance(arguments.get("path"), str):
allowed = {"path"} if name == READ_SKILL_FILE_TOOL else {"path", "offset", "limit"}
if not set(arguments).issubset(allowed) or "path" not in arguments or not isinstance(arguments.get("path"), str):
raise SkillExecutionError(
"pi_skill_tool_call_invalid",
"read_skill_file requires only the string 'path' argument",
f"{name} requires a string 'path' argument",
)
if name == READ_TOOL:
for key in ("offset", "limit"):
if key in arguments and (
isinstance(arguments[key], bool)
or not isinstance(arguments[key], int)
or arguments[key] < 1
):
raise SkillExecutionError(
"pi_skill_tool_call_invalid", f"{key} must be a positive integer"
)
if arguments.get("limit", 2000) > 2000:
raise SkillExecutionError(
"pi_skill_tool_call_invalid", "limit must not exceed 2000"
)
path = arguments["path"]
if path == "SKILL.md":
raise SkillExecutionError(
"pi_skill_tool_call_invalid", "SKILL.md can only be loaded with load_skill"
)
if path in self.cache:
return self.cache[path]
cache_key = path if name == READ_SKILL_FILE_TOOL else (path, arguments.get("offset", 1), arguments.get("limit"))
if cache_key in self.cache:
return self.cache[cache_key]
try:
result = read_skill_reference(self.snapshot, path)
except ValueError as exc:
@@ -720,8 +753,25 @@ class ReadOnlySkillRuntime:
raise SkillExecutionError(
"pi_skill_tool_call_invalid", f"Resource is not in the manifest: {path}"
)
if name == READ_TOOL:
lines = result.get("content", "").splitlines(keepends=True)
offset = arguments.get("offset", 1)
limit = arguments.get("limit", 2000)
start = offset - 1
if start >= len(lines) and lines:
raise SkillExecutionError(
"pi_skill_tool_call_invalid", f"offset {offset} is beyond end of file"
)
selected = lines[start : start + limit]
result = dict(result)
result["content"] = "".join(selected)
result["offset"] = offset
result["line_count"] = len(selected)
result["total_lines"] = len(lines)
if start + len(selected) < len(lines):
result["next_offset"] = start + len(selected) + 1
self._account(result)
self.cache[path] = result
self.cache[cache_key] = result
return result
@@ -1103,7 +1153,7 @@ class SkillToolRegistry:
@staticmethod
def allowed_name(loaded):
return READ_SKILL_FILE_TOOL if loaded else LOAD_SKILL_TOOL
return READ_TOOL if loaded else LOAD_SKILL_TOOL
@classmethod
def validate(cls, call, loaded, seen):
@@ -1113,10 +1163,12 @@ class SkillToolRegistry:
raise SkillExecutionError(
"pi_skill_tool_call_invalid", "Tool call_id is missing or duplicated"
)
if name != cls.allowed_name(loaded):
allowed = cls.allowed_name(loaded)
aliases = {READ_TOOL, READ_SKILL_FILE_TOOL} if loaded else {LOAD_SKILL_TOOL}
if name not in aliases:
raise SkillExecutionError(
"pi_skill_tool_call_invalid",
f"Tool is not registered in the current phase: {name!r}",
f"Tool is not registered in the current phase: {name!r}",
)
return call_id, name, _parse_args(call.get("arguments"))
@@ -1130,7 +1182,11 @@ class PiSkillAgentLoop:
self.session = session
self.limits = limits
def run(self, payload, trace, stream=False, on_delta=None, on_activity=None):
def run(
self, payload, trace, stream=False, on_delta=None, on_activity=None,
on_round_start=None, on_round_end=None, on_tool_call_start=None,
on_tool_call_end=None,
):
field = self.adapter.history_field
history = deepcopy(payload[field])
base_payload = {
@@ -1147,14 +1203,29 @@ class PiSkillAgentLoop:
trace["tool_choice_mode"] = "provider_default"
seen = set()
final_candidate = []
for round_index in range(self.limits["max_tool_rounds"] + 1):
tools = skill_tool_definitions(self.adapter.protocol, loaded=loaded)
tools = skill_tool_definitions(
self.adapter.protocol,
loaded=loaded,
read_tool=READ_TOOL if stream else READ_SKILL_FILE_TOOL,
)
if on_round_start is not None:
on_round_start(round_index, bool(tools))
candidate = []
def collect_candidate(value, *_ignored):
candidate.append(value)
if on_delta is not None:
on_delta(value)
data = self.adapter.request(
base_payload,
history,
tools,
stream=stream,
on_activity=on_activity,
on_delta=collect_candidate if stream else None,
)
normalized = self.adapter.normalize(data)
self.session.append(
@@ -1177,47 +1248,22 @@ class PiSkillAgentLoop:
"pi_skill_response_invalid",
"Provider response contains no final text",
)
self.adapter.append_native_items(history, normalized)
if stream and on_round_end is not None:
on_round_end(round_index, False)
if stream:
# A tools-enabled response cannot be known to be the formal
# answer until its stream has ended. Keep that candidate
# out of history and use a tools-free final phase so the UI
# can safely display its first text delta immediately.
if on_activity is not None:
on_activity("generating")
self.session.append(
"final_phase_started", protocol=self.adapter.protocol
)
final_data = self.adapter.final_request(
base_payload,
history,
stream=True,
on_delta=on_delta,
on_activity=on_activity,
)
final_normalized = self.adapter.normalize(final_data)
if final_normalized.tool_calls:
raise SkillExecutionError(
"pi_skill_response_invalid",
"Provider returned a tool call after Skill tools were removed",
)
if final_normalized.final_text is None:
raise SkillExecutionError(
"pi_skill_response_invalid",
"Provider final phase contains no formal text",
)
self.adapter.append_native_items(history, final_normalized)
normalized = final_normalized
else:
self.adapter.append_native_items(history, normalized)
final_candidate = candidate
trace["skill_loaded"] = True
if trace["load_source"] == "none":
trace["load_source"] = "tool_call"
self.session.commit(
history, self.snapshot, self.adapter.protocol
)
return normalized.final_text
return "".join(final_candidate) or normalized.final_text
self.adapter.append_native_items(history, normalized)
if stream and on_round_end is not None:
on_round_end(round_index, True)
if round_index >= self.limits["max_tool_rounds"]:
raise SkillExecutionError(
"pi_skill_tool_limit_exceeded", "Skill tool round limit exceeded"
@@ -1239,6 +1285,10 @@ class PiSkillAgentLoop:
accepted=False,
error={"code": exc.code, "message": exc.message},
)
if on_tool_call_end is not None:
on_tool_call_end(
call_id, name, "error", arguments.get("path"), round_index
)
raise
seen.add(call_id)
planned.append((call_id, name, arguments))
@@ -1261,6 +1311,10 @@ class PiSkillAgentLoop:
on_activity(
"loading_skill" if name == LOAD_SKILL_TOOL else "reading_skill"
)
if on_tool_call_start is not None:
on_tool_call_start(
call_id, name, arguments.get("path"), round_index
)
try:
output = runtime.execute(name, arguments)
except SkillExecutionError as exc:
@@ -1274,6 +1328,10 @@ class PiSkillAgentLoop:
"tool_call_settled", call_id=call_id, name=name,
outcome="success", path=output.get("path"), output=output,
)
if on_tool_call_end is not None:
on_tool_call_end(
call_id, name, "success", output.get("path"), round_index
)
wire_result = self.adapter.serialize_tool_result(call_id, output)
history.append(wire_result)
self.session.append(
@@ -1381,6 +1439,8 @@ class SkillExecutionRouter:
protocol, post_json, endpoint, headers, timeout, proxies, payload,
snapshot, session_key, persist_context=True, trace=None,
stream=False, post_stream=None, on_delta=None, on_activity=None,
on_round_start=None, on_round_end=None, on_tool_call_start=None,
on_tool_call_end=None,
):
config = get_skill_config()
if not config.get("allow_call", False):
@@ -1447,6 +1507,10 @@ class SkillExecutionRouter:
stream=stream,
on_delta=on_delta,
on_activity=on_activity,
on_round_start=on_round_start,
on_round_end=on_round_end,
on_tool_call_start=on_tool_call_start,
on_tool_call_end=on_tool_call_end,
)
return result, active_trace, session.conversation(snapshot)
except Exception as exc:
@@ -1504,7 +1568,8 @@ class SkillRequestContext:
def execute(
self, post_json, endpoint, headers, timeout, proxies, payload,
session_key, persist_context=True, stream=False, post_stream=None,
on_delta=None, on_activity=None,
on_delta=None, on_activity=None, on_round_start=None, on_round_end=None,
on_tool_call_start=None, on_tool_call_end=None,
):
if not self.enabled:
raise RuntimeError("Skill execution requires selected skill_options")
@@ -1525,6 +1590,10 @@ class SkillRequestContext:
post_stream=post_stream,
on_delta=on_delta,
on_activity=on_activity,
on_round_start=on_round_start,
on_round_end=on_round_end,
on_tool_call_start=on_tool_call_start,
on_tool_call_end=on_tool_call_end,
)
return result, conversation
except Exception as exc:
+173 -23
View File
@@ -20,6 +20,14 @@ const MESSAGES = {
reading_skill: "Reading Skill files…",
reasoning: "Thinking…",
generating: "Generating…",
drafting: "Model note…",
toolRunning: "Tool running…",
tool_running: "Tool running…",
promoting: "Preparing answer…",
activityExpand: "Show activity",
activityCollapse: "Hide activity",
processTitle: "Process & status",
intermediate: "Model note",
displaying: "Displaying result…",
complete: "Complete",
copy: "Copy",
@@ -37,6 +45,14 @@ const MESSAGES = {
reading_skill: "正在读取 Skill 文件…",
reasoning: "正在思考…",
generating: "正在生成…",
drafting: "模型中间说明…",
toolRunning: "工具执行中…",
tool_running: "工具执行中…",
promoting: "正在整理答案…",
activityExpand: "展开过程",
activityCollapse: "收起过程",
processTitle: "过程与状态",
intermediate: "模型中间说明",
displaying: "正在显示结果…",
complete: "完成",
copy: "复制",
@@ -157,7 +173,7 @@ function renderNow(state) {
state.host.dataset.hasContent = raw ? "true" : "false";
if (!raw) {
state.content.replaceChildren();
if (!["waiting", "loading_skill", "reading_skill", "reasoning", "error"].includes(state.statusKey)) {
if (!["waiting", "loading_skill", "reading_skill", "reasoning", "drafting", "tool_running", "promoting", "generating", "displaying", "error"].includes(state.statusKey)) {
const placeholder = document.createElement("div");
placeholder.className = "empty";
placeholder.textContent = message("empty");
@@ -199,17 +215,64 @@ function setStatus(state, key, detail = "") {
state.statusDetail = detail;
const visible = [
"waiting", "loading_skill", "reading_skill", "reasoning",
"generating", "displaying", "error",
"drafting", "tool_running", "promoting", "generating", "displaying", "complete", "error",
].includes(key);
state.host.dataset.state = key;
state.status.textContent = key === "error" && detail
? `${message("error")}: ${detail}`
: message(key);
state.status.hidden = !visible;
// Status is rendered in the dedicated lower process box, so update its
// visibility whenever the state changes.
renderActivity(state);
}
function appendActivity(state, entry) {
state.activityLog.push({ ...entry, time: Date.now() });
if (state.activityLog.length > 20) state.activityLog.splice(0, state.activityLog.length - 20);
state.activityExpanded = true;
renderActivity(state);
}
function renderActivity(state) {
if (!state.activityPanel) return;
state.activityPanel.replaceChildren();
const latest = state.currentActivity;
if (latest) {
const current = document.createElement("div");
current.className = "activity-current";
current.textContent = latest.detail ? `${latest.label} · ${latest.detail}` : latest.label;
state.activityPanel.append(current);
}
const log = document.createElement("div");
log.className = "activity-log";
const entries = state.activityExpanded ? state.activityLog : state.activityLog.slice(-2);
for (const entry of entries) {
const row = document.createElement("div");
row.className = `activity-entry activity-${entry.type || "info"}`;
row.textContent = entry.text || "";
log.append(row);
}
if (state.candidateText) {
const candidate = document.createElement("div");
candidate.className = "activity-candidate";
candidate.textContent = `${message("intermediate")}: ${state.candidateText}`;
log.append(candidate);
}
state.activityPanel.append(log);
// Keep the process box visible for the complete state as well, so the
// final status and activity history remain available after rendering.
const statusVisible = !state.status.hidden && state.statusKey !== "ready";
state.activityWrap.hidden = !latest && !state.activityLog.length && !state.candidateText && !statusVisible;
state.activityToggle.hidden = state.activityLog.length < 3 && !state.candidateText;
state.activityToggle.textContent = state.activityExpanded
? message("activityCollapse") : message("activityExpand");
}
function updateCopyAvailability(state) {
state.copyButton.disabled = !(state.finalText || state.receivedText || state.displayedText);
// Only confirmed answer text is copyable. Intermediate candidate text,
// reasoning and activity records must never leak through the copy action.
state.copyButton.disabled = !(state.answerText || state.finalText);
}
function setCopyState(state, copyState) {
@@ -239,7 +302,7 @@ function showCopyToast(success) {
}
async function copyRawText(state) {
const text = state.sourceEnded ? state.finalText : (state.receivedText || state.displayedText);
const text = state.answerText || state.finalText;
if (!text) return;
try {
if (navigator.clipboard?.writeText && window.isSecureContext) {
@@ -293,8 +356,10 @@ function finishTypingIfReady(state) {
if (state.terminalError) {
setStatus(state, "error", state.terminalError);
} else {
state.currentActivity = null;
setStatus(state, "complete");
}
renderActivity(state);
updateCopyAvailability(state);
scheduleRender(state, true);
return true;
@@ -399,17 +464,30 @@ function createPreview(node) {
<style>
:host { display: block; width: 100%; height: 100%; }
.preview {
position: relative; height: 100%; min-height: 200px; overflow: hidden;
box-sizing: border-box; border: 1px solid var(--border-color, #484848);
border-radius: 8px; background: var(--comfy-input-bg, #181818);
position: relative; display: flex; flex-direction: column; gap: 7px; height: 100%; min-height: 200px; overflow: hidden;
box-sizing: border-box; border: 0; border-radius: 8px;
color: var(--input-text, var(--fg-color, #ddd));
font: 13px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
}
.result-wrap {
order: 1; position: relative; flex: 1 1 auto; min-height: 0; width: 100%; overflow: hidden;
box-sizing: border-box; border: 1px solid var(--border-color, #484848); border-radius: 8px;
background: var(--comfy-input-bg, #181818);
}
.content {
width: 100%; height: 100%; overflow: auto; box-sizing: border-box;
padding: 12px 14px 22px; overflow-wrap: anywhere;
scrollbar-color: color-mix(in srgb, currentColor 35%, transparent) transparent;
}
.activity-wrap { order: 2; position: relative; z-index: 1; flex: 0 0 auto; width: 100%; max-height: 112px; min-height: 34px; overflow: auto; box-sizing: border-box; border: 1px solid color-mix(in srgb, var(--border-color, #484848) 80%, transparent); border-radius: 8px; background: color-mix(in srgb, var(--comfy-input-bg, #181818) 92%, #000 8%); font-size: 11px; opacity: .88; }
.activity-wrap > .status { position: static; display: block; max-width: none; margin: 5px 8px 0; padding: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; background: transparent; border-radius: 0; font-size: 11px; opacity: .85; }
.activity-title { padding: 6px 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: .04em; opacity: .55; }
.activity-panel { padding: 2px 8px 2px; }
.activity-current { color: var(--input-text, var(--fg-color, #ddd)); font-weight: 600; }
.activity-log { margin-top: 2px; }
.activity-entry, .activity-candidate { white-space: pre-wrap; overflow-wrap: anywhere; opacity: .72; }
.activity-candidate { max-height: 42px; overflow: hidden; color: color-mix(in srgb, currentColor 78%, #7c8cf8 22%); }
.activity-toggle { border: 0; background: transparent; color: inherit; opacity: .7; cursor: pointer; padding: 2px 5px 5px; font-size: 10px; }
.empty { height: 100%; display: grid; place-items: center; text-align: center; opacity: .42; }
.copy {
position: absolute; z-index: 2; top: 6px; right: 8px; width: 28px; height: 28px;
@@ -427,20 +505,12 @@ function createPreview(node) {
.copy[data-copy-state="copied"] .check-icon { display: block; }
.copy[data-copy-state="failed"] { color: #f87171; opacity: 1; }
.copy:disabled { display: none; }
.status {
position: absolute; z-index: 2; left: 10px; bottom: 7px; max-width: calc(100% - 20px);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; pointer-events: none;
padding: 2px 7px; border-radius: 999px; background: color-mix(in srgb, var(--comfy-input-bg, #181818) 84%, currentColor 16%);
color: inherit; font-size: 11px; opacity: .72;
}
.status { color: inherit; pointer-events: none; }
.preview[data-state="waiting"] .status,
.preview[data-state="loading_skill"] .status,
.preview[data-state="reading_skill"] .status,
.preview[data-state="reasoning"] .status,
.preview[data-state="error"][data-has-content="false"] .status {
left: 50%; bottom: 50%; max-width: calc(100% - 32px);
transform: translate(-50%, 50%); padding: 5px 10px;
}
.preview[data-state="error"][data-has-content="false"] .status { transform: none; }
.preview[data-state="error"] .status { color: #fca5a5; opacity: .95; }
h1, h2, h3, h4, h5, h6 { margin: 1.1em 0 .55em; line-height: 1.25; }
h1 { font-size: 1.55em; } h2 { font-size: 1.35em; } h3 { font-size: 1.18em; }
@@ -464,16 +534,24 @@ function createPreview(node) {
}
</style>
<div class="preview" data-state="idle" data-has-content="false">
<button class="copy" type="button" data-copy-state="idle">
<section class="result-wrap">
<div class="content"></div>
<button class="copy" type="button" data-copy-state="idle">
<svg class="copy-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M15 9V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h3"></path></svg>
<svg class="check-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12 4 4L19 6"></path></svg>
</button>
<div class="content"></div>
<span class="status" hidden aria-live="polite"></span>
</button>
</section>
<section class="activity-wrap" hidden>
<div class="activity-title"></div>
<span class="status" hidden aria-live="polite"></span>
<div class="activity-panel"></div>
<button class="activity-toggle" type="button" hidden></button>
</section>
</div>`;
const state = {
displayedText: "",
answerText: "",
receivedText: "",
finalText: "",
pendingUnits: [],
@@ -491,16 +569,30 @@ function createPreview(node) {
copyTimer: null,
statusKey: "ready",
statusDetail: "",
candidateText: "",
activityLog: [],
currentActivity: null,
activityExpanded: false,
root,
host: shadow.querySelector(".preview"),
content: shadow.querySelector(".content"),
status: shadow.querySelector(".status"),
status: shadow.querySelector(".activity-wrap .status"),
copyButton: shadow.querySelector("button"),
activityWrap: shadow.querySelector(".activity-wrap"),
activityTitle: shadow.querySelector(".activity-title"),
activityPanel: shadow.querySelector(".activity-panel"),
activityToggle: shadow.querySelector(".activity-toggle"),
};
setStatus(state, "ready");
state.activityTitle.textContent = message("processTitle");
setCopyState(state, "idle");
updateCopyAvailability(state);
state.copyButton.addEventListener("click", () => copyRawText(state));
state.activityToggle.addEventListener("click", () => {
state.activityExpanded = !state.activityExpanded;
renderActivity(state);
});
renderActivity(state);
root.addEventListener("pointerdown", event => event.stopPropagation());
root.addEventListener("wheel", event => event.stopPropagation(), { passive: true });
@@ -536,6 +628,7 @@ function setFinalText(node, value) {
const unchanged = state.displayedText === text && !state.streaming;
stopTyping(state);
state.displayedText = text;
state.answerText = text;
state.receivedText = text;
state.finalText = text;
state.pendingUnits = [];
@@ -577,13 +670,21 @@ function receiveStreamEvent(event) {
state.promptId = data.prompt_id ?? null;
state.lastSeq = Number.isFinite(sequence) ? sequence : -1;
state.displayedText = "";
state.answerText = "";
state.receivedText = "";
state.finalText = "";
state.candidateText = "";
state.activityLog = [];
state.currentActivity = null;
state.activityExpanded = false;
state.currentRound = 0;
state.roundHasToolCall = false;
state.pendingUnits = [];
state.sourceEnded = false;
state.terminalError = "";
state.streaming = true;
setStatus(state, "waiting");
renderActivity(state);
updateCopyAvailability(state);
scheduleRender(state, true);
continue;
@@ -592,7 +693,51 @@ function receiveStreamEvent(event) {
if (Number.isFinite(sequence) && sequence <= state.lastSeq) continue;
if (Number.isFinite(sequence)) state.lastSeq = sequence;
if (data.phase === "activity") {
if (data.phase === "round_start") {
state.currentRound = Number(data.round) || 0;
state.roundHasToolCall = false;
state.candidateText = "";
state.currentActivity = { label: message("waiting"), detail: `Round ${state.currentRound + 1}` };
appendActivity(state, { type: "round", text: `Round ${state.currentRound + 1}` });
setStatus(state, "waiting");
} else if (data.phase === "candidate_delta") {
state.candidateText += String(data.delta || "");
state.currentActivity = { label: message("drafting"), detail: `Round ${state.currentRound + 1}` };
setStatus(state, "drafting");
renderActivity(state);
} else if (data.phase === "tool_call_start") {
state.roundHasToolCall = true;
const detail = data.path ? `${data.tool} · ${data.path}` : data.tool;
state.currentActivity = { label: message("toolRunning"), detail };
appendActivity(state, { type: "tool", text: `${message("toolRunning")} ${detail}` });
setStatus(state, "tool_running");
} else if (data.phase === "tool_call_end") {
const detail = data.path ? `${data.tool} · ${data.path}` : data.tool;
appendActivity(state, { type: "tool", text: `${detail} · ${data.status || "success"}` });
renderActivity(state);
} else if (data.phase === "round_end") {
const hasTools = Boolean(data.has_tool_calls);
state.roundHasToolCall = hasTools;
if (hasTools) {
state.candidateText = "";
state.currentActivity = { label: message("toolRunning"), detail: `Round ${state.currentRound + 1} complete` };
} else {
state.answerText = state.candidateText;
state.receivedText = state.answerText;
state.finalText = state.answerText;
state.candidateText = "";
state.currentActivity = { label: message("generating"), detail: "" };
setStatus(state, state.answerText ? "promoting" : "generating");
if (state.answerText) {
state.pendingUnits = splitTextUnits(state.answerText);
state.displayedText = "";
state.sourceEnded = false;
startTyping(state);
}
}
renderActivity(state);
scheduleRender(state, true);
} else if (data.phase === "activity") {
const activity = [
"loading_skill", "reading_skill", "reasoning", "generating",
].includes(data.activity) ? data.activity : null;
@@ -608,6 +753,10 @@ function receiveStreamEvent(event) {
setStatus(state, "displaying");
}
enqueueFinalText(state, data.text ?? state.receivedText);
state.answerText = String(data.text ?? state.receivedText);
state.candidateText = "";
state.currentActivity = null;
renderActivity(state);
} else if (data.phase === "error") {
state.sourceEnded = true;
state.finalText = state.receivedText;
@@ -625,6 +774,7 @@ function refreshLocale() {
for (const node of app.graph?._nodes || []) {
const state = node?._ycyyApiResultPreview;
if (!state) continue;
if (state.activityTitle) state.activityTitle.textContent = message("processTitle");
setCopyState(state, state.copyButton.dataset.copyState || "idle");
setStatus(state, state.statusKey, state.statusDetail);
scheduleRender(state);