Files
jinchan 3deaa20708 fix(template): 修复工作流输出结果选择兼容性问题
- 修改调用处使用 workflow.get("output") 避免直接访问导致异常
- select_workflow_output 函数支持默认 output_key 为 "output"
- 兼容缺少 output 描述符或 key 的工作流定义
- 增强对 COMFY_APP 输出格式的支持,防止因缺少描述符导致失败
- 完善函数注释,明确默认行为和兼容方案
2026-09-19 19:14:16 +08:00

717 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""画布模板工作流协议(templateProtocolVersion = 2)的解析逻辑。
只处理协议语义,不含任何 HTTP 调用,供批量套版 AI hook 与单元格生成共用同一套解析器。
"""
import copy
import hashlib
import json
# 批量路由与执行上下文的保留 key,不能作为 templateRules key 或 binding inputKey;
# 存量数据可能把它们泄漏在 input 中,解析时必须忽略
RESERVED_INPUT_KEYS = frozenset({"workId", "rowNo", "sourceKey", "attempt", "templateKey", "templateId"})
PROTOCOL_VERSION = 2
SUPPORTED_WORKFLOW_TYPE = "COMFY_APP"
# 引用图层时属性与图层类型的对应关系
LAYER_PROPERTY_TYPES = {"src": "image", "content": "text"}
# AI 结果图层类型与取值属性的对应关系
RESULT_TYPE_PROPERTIES = {"image": "src", "text": "content"}
_MISSING = object()
class WorkflowProtocolError(Exception):
"""带协议错误码与定位信息的校验/解析失败"""
def __init__(self, code, message, **location):
super().__init__(message)
self.code = code
self.message = message
self.location = {key: value for key, value in location.items() if value is not None}
def strip_reserved_keys(input_values):
"""剔除行数据中的保留 key,得到只含模板字段的输入"""
return {key: value for key, value in input_values.items() if key not in RESERVED_INPUT_KEYS}
def index_layers(root):
"""按 ID 索引图层树,返回 (id -> 图层, 重复出现的 ID 集合)"""
index = {}
duplicated = set()
def walk(node):
node_id = node.get("id")
if node_id is not None:
if node_id in index:
duplicated.add(node_id)
else:
index[node_id] = node
children = node.get("layers")
if isinstance(children, list):
for child in children:
if isinstance(child, dict):
walk(child)
walk(root)
return index, duplicated
def has_ai_rules(template_rules):
return bool(ai_result_keys(template_rules))
def ai_result_keys(template_rules):
"""按模板声明顺序返回所有 AI 结果列的 rule key(workflows 非空)"""
keys = []
for key, rule in template_rules.items():
if isinstance(rule, dict) and rule.get("workflows"):
keys.append(key)
return keys
def get_workflow(template_rules, result_rule_key):
"""取出第 2 版协议要求的唯一 WorkflowConfig"""
rule = template_rules.get(result_rule_key)
if not isinstance(rule, dict):
raise WorkflowProtocolError("TEMPLATE_INVALID", f"规则 '{result_rule_key}' 不存在", resultRuleKey=result_rule_key)
workflows = rule.get("workflows")
if not isinstance(workflows, list) or len(workflows) != 1:
raise WorkflowProtocolError(
"TEMPLATE_INVALID",
f"规则 '{result_rule_key}' 的 workflows 必须恰好包含一项,当前为 "
f"{len(workflows) if isinstance(workflows, list) else type(workflows).__name__}",
resultRuleKey=result_rule_key,
)
workflow = workflows[0]
if not isinstance(workflow, dict):
raise WorkflowProtocolError(
"TEMPLATE_INVALID", f"规则 '{result_rule_key}' 的 workflows[0] 必须是 JSON 对象", resultRuleKey=result_rule_key
)
return workflow
def parse_pointer(pointer, result_rule_key=None):
"""解析 binding 的 JSON Pointer,返回 token 列表"""
if not isinstance(pointer, str) or not pointer.startswith("/"):
raise WorkflowProtocolError(
"TEMPLATE_INVALID",
f"binding 路径必须是以 / 开头的 JSON Pointer,当前为: {pointer!r}",
resultRuleKey=result_rule_key,
parameterPath=pointer if isinstance(pointer, str) else None,
)
return [token.replace("~1", "/").replace("~0", "~") for token in pointer[1:].split("/")]
def pointer_get(root, pointer, default=_MISSING, result_rule_key=None):
"""按 JSON Pointer 读取值,路径不存在时返回 default"""
current = root
for token in parse_pointer(pointer, result_rule_key):
if isinstance(current, dict):
if token not in current:
return default
current = current[token]
elif isinstance(current, list):
index = _as_list_index(token)
if index is None or index >= len(current):
return default
current = current[index]
else:
return default
return current
def pointer_set(root, pointer, value, result_rule_key=None):
"""按 JSON Pointer 写入值;父级为 dict 且缺失时自动补空对象"""
tokens = parse_pointer(pointer, result_rule_key)
current = root
for token in tokens[:-1]:
if isinstance(current, dict):
child = current.get(token, _MISSING)
if child is _MISSING or not isinstance(child, (dict, list)):
current[token] = {}
current = current[token]
elif isinstance(current, list):
index = _as_list_index(token)
if index is None or index >= len(current):
raise WorkflowProtocolError(
"PARAMETER_INVALID",
f"binding 路径 '{pointer}' 的数组下标 '{token}' 越界",
resultRuleKey=result_rule_key,
parameterPath=pointer,
)
current = current[index]
else:
raise WorkflowProtocolError(
"PARAMETER_INVALID",
f"binding 路径 '{pointer}' 的父级不是对象或数组",
resultRuleKey=result_rule_key,
parameterPath=pointer,
)
last = tokens[-1]
if isinstance(current, dict):
current[last] = value
return
if isinstance(current, list):
index = _as_list_index(last)
if index is None or index >= len(current):
raise WorkflowProtocolError(
"PARAMETER_INVALID",
f"binding 路径 '{pointer}' 的数组下标 '{last}' 越界",
resultRuleKey=result_rule_key,
parameterPath=pointer,
)
current[index] = value
return
raise WorkflowProtocolError(
"PARAMETER_INVALID",
f"binding 路径 '{pointer}' 的父级不是对象或数组",
resultRuleKey=result_rule_key,
parameterPath=pointer,
)
def _as_list_index(token):
try:
index = int(token)
except (TypeError, ValueError):
return None
return index if index >= 0 else None
def validate_workflows(template_data, layer_root):
"""校验模板中的 workflows 配置。
校验不通过时只记录告警、不抛出:避免整批因单个 workflow 配置问题而全部失败。
真正无法执行的条目仍会在执行期(build_execution_order / resolve_workflow_params /
select_workflow_output)被逐条捕获为该条失败,不影响其他条目。
无 AI 结果规则时直接返回。
"""
template_rules = template_data.get("templateRules") or {}
result_keys = ai_result_keys(template_rules)
if not result_keys:
return
try:
version = template_data.get("templateProtocolVersion")
if version != PROTOCOL_VERSION:
raise WorkflowProtocolError(
"TEMPLATE_INVALID",
f"存在非空 workflows 时 templateProtocolVersion 必须为 {PROTOCOL_VERSION},当前为: {version!r}",
)
layer_index, duplicated_ids = index_layers(layer_root)
result_key_set = set(result_keys)
for result_rule_key in result_keys:
workflow = get_workflow(template_rules, result_rule_key)
_validate_workflow(
workflow, result_rule_key, template_rules, result_key_set, layer_index, duplicated_ids
)
# 依赖成环仅在此告警;执行期 build_execution_order 会再次检测并逐条失败
build_execution_order(template_rules)
except WorkflowProtocolError as e:
print(f"[template_workflow] workflows 校验告警(不阻断,执行期逐条校验): {e.code} {e.message}")
except Exception as e:
print(f"[template_workflow] workflows 校验告警(不阻断,执行期逐条校验): {e}")
def _validate_workflow(workflow, result_rule_key, template_rules, result_key_set, layer_index, duplicated_ids):
rule_type = template_rules[result_rule_key].get("type")
if rule_type not in RESULT_TYPE_PROPERTIES:
raise WorkflowProtocolError(
"TEMPLATE_INVALID",
f"AI 结果规则 '{result_rule_key}' 的 type 必须是 image 或 text,当前为: {rule_type!r}",
resultRuleKey=result_rule_key,
)
workflow_key = workflow.get("workflowKey")
if not isinstance(workflow_key, str) or not workflow_key.strip():
raise WorkflowProtocolError(
"TEMPLATE_INVALID", f"规则 '{result_rule_key}' 的 workflowKey 不能为空", resultRuleKey=result_rule_key
)
workflow_type = workflow.get("type")
if workflow_type != SUPPORTED_WORKFLOW_TYPE:
raise WorkflowProtocolError(
"TEMPLATE_INVALID",
f"规则 '{result_rule_key}' 的 workflow type 暂不支持: {workflow_type!r}",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
)
tool_name = workflow.get("toolName")
if not isinstance(tool_name, str) or not tool_name.strip():
raise WorkflowProtocolError(
"TEMPLATE_INVALID",
f"规则 '{result_rule_key}' 的 toolName 不能为空",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
)
if not isinstance(workflow.get("params"), dict):
raise WorkflowProtocolError(
"TEMPLATE_INVALID",
f"规则 '{result_rule_key}' 的 params 必须是 JSON 对象",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
)
output = workflow.get("output")
if not isinstance(output, dict) or not output.get("key"):
raise WorkflowProtocolError(
"TEMPLATE_INVALID",
f"规则 '{result_rule_key}' 缺少有效的 output.key",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
)
item_index = output.get("itemIndex", 0)
if not isinstance(item_index, int) or isinstance(item_index, bool) or item_index < 0:
raise WorkflowProtocolError(
"OUTPUT_INVALID",
f"规则 '{result_rule_key}' 的 output.itemIndex 必须是非负整数,当前为: {item_index!r}",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
)
bindings = workflow.get("bindings")
if not isinstance(bindings, dict):
raise WorkflowProtocolError(
"TEMPLATE_INVALID",
f"规则 '{result_rule_key}' 的 bindings 必须是 JSON 对象",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
)
parsed_paths = []
for pointer, binding in bindings.items():
tokens = parse_pointer(pointer, result_rule_key)
for existing_pointer, existing_tokens in parsed_paths:
if _is_prefix(tokens, existing_tokens) or _is_prefix(existing_tokens, tokens):
raise WorkflowProtocolError(
"TEMPLATE_INVALID",
f"规则 '{result_rule_key}' 的 binding 路径重复或父子重叠: '{existing_pointer}' 与 '{pointer}'",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
parameterPath=pointer,
)
parsed_paths.append((pointer, tokens))
if not isinstance(binding, dict):
raise WorkflowProtocolError(
"TEMPLATE_INVALID",
f"规则 '{result_rule_key}' 的 binding '{pointer}' 必须是 JSON 对象",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
parameterPath=pointer,
)
source = binding.get("source")
if source == "constant":
continue
if source == "row":
_validate_row_binding(binding, pointer, result_rule_key, workflow_key, template_rules, result_key_set)
continue
if source == "layer":
_validate_layer_binding(
binding, pointer, result_rule_key, workflow_key, layer_index, duplicated_ids
)
continue
raise WorkflowProtocolError(
"TEMPLATE_INVALID",
f"规则 '{result_rule_key}' 的 binding '{pointer}' source 不合法: {source!r}",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
parameterPath=pointer,
)
def _validate_row_binding(binding, pointer, result_rule_key, workflow_key, template_rules, result_key_set):
input_key = binding.get("inputKey")
if not isinstance(input_key, str) or not input_key:
raise WorkflowProtocolError(
"TEMPLATE_INVALID",
f"规则 '{result_rule_key}' 的 binding '{pointer}' 缺少 inputKey",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
parameterPath=pointer,
)
if input_key in RESERVED_INPUT_KEYS:
raise WorkflowProtocolError(
"INPUT_KEY_CONFLICT",
f"inputKey '{input_key}' 与保留 key 冲突",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
inputKey=input_key,
parameterPath=pointer,
)
if input_key in result_key_set:
raise WorkflowProtocolError(
"INPUT_KEY_CONFLICT",
f"inputKey '{input_key}' 是 AI 结果列,AI 结果之间的依赖必须使用 source=layer",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
inputKey=input_key,
parameterPath=pointer,
)
# inputKey 允许等于普通模板字段:该列同时用于画布替换和 AI 参数
rule = template_rules.get(input_key)
if isinstance(rule, dict) and rule.get("workflows"):
raise WorkflowProtocolError(
"INPUT_KEY_CONFLICT",
f"inputKey '{input_key}' 绑定的规则含 workflows",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
inputKey=input_key,
parameterPath=pointer,
)
def _validate_layer_binding(binding, pointer, result_rule_key, workflow_key, layer_index, duplicated_ids):
layer_id = binding.get("layerId")
property_name = binding.get("property")
if property_name not in LAYER_PROPERTY_TYPES:
raise WorkflowProtocolError(
"LAYER_REFERENCE_INVALID",
f"规则 '{result_rule_key}' 的 binding '{pointer}' property 必须是 src 或 content,当前为: {property_name!r}",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
layerId=layer_id,
parameterPath=pointer,
)
node = layer_index.get(layer_id)
if node is None:
raise WorkflowProtocolError(
"LAYER_REFERENCE_INVALID",
f"规则 '{result_rule_key}' 引用的图层 '{layer_id}' 不存在",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
layerId=layer_id,
parameterPath=pointer,
)
if layer_id in duplicated_ids:
raise WorkflowProtocolError(
"LAYER_REFERENCE_INVALID",
f"规则 '{result_rule_key}' 引用的图层 '{layer_id}' 在模板中不唯一",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
layerId=layer_id,
parameterPath=pointer,
)
expected_type = LAYER_PROPERTY_TYPES[property_name]
if node.get("type") != expected_type:
raise WorkflowProtocolError(
"LAYER_REFERENCE_INVALID",
f"规则 '{result_rule_key}' 的 binding '{pointer}' 要求 {expected_type} 图层,"
f"图层 '{layer_id}' 类型为 {node.get('type')!r}",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
layerId=layer_id,
parameterPath=pointer,
)
def _is_prefix(tokens, other):
return len(tokens) <= len(other) and other[: len(tokens)] == tokens
def build_execution_order(template_rules):
"""按 source=layer 依赖对 AI 规则拓扑排序,成环时抛 DEPENDENCY_CYCLE"""
result_keys = ai_result_keys(template_rules)
owner_by_layer = {}
for key in result_keys:
layer_id = template_rules[key].get("id")
if layer_id is not None:
owner_by_layer[layer_id] = key
dependencies = {}
for key in result_keys:
workflow = get_workflow(template_rules, key)
upstream_keys = []
for pointer, binding in (workflow.get("bindings") or {}).items():
if not isinstance(binding, dict) or binding.get("source") != "layer":
continue
upstream = owner_by_layer.get(binding.get("layerId"))
if upstream is None:
continue
if upstream == key:
raise WorkflowProtocolError(
"DEPENDENCY_CYCLE",
f"规则 '{key}' 的 binding '{pointer}' 引用了自身图层",
resultRuleKey=key,
layerId=binding.get("layerId"),
parameterPath=pointer,
)
upstream_keys.append(upstream)
dependencies[key] = upstream_keys
order = []
state = {}
def visit(key, path):
status = state.get(key)
if status == "done":
return
if status == "visiting":
cycle = " -> ".join(path + [key])
raise WorkflowProtocolError("DEPENDENCY_CYCLE", f"AI 图层依赖成环: {cycle}", resultRuleKey=key)
state[key] = "visiting"
for upstream in dependencies[key]:
visit(upstream, path + [key])
state[key] = "done"
order.append(key)
for key in result_keys:
visit(key, [])
return order
def resolve_workflow_params(template_rules, layer_index, input_values, result_rule_key, upstream_results):
"""按 bindings 解析出本行的工作流参数,返回深拷贝后的 params"""
workflow = get_workflow(template_rules, result_rule_key)
params = copy.deepcopy(workflow.get("params") or {})
for pointer, binding in (workflow.get("bindings") or {}).items():
source = binding.get("source")
if source == "constant":
# 固定值即 params 中已保存的原生值,保持不动
continue
if source == "row":
input_key = binding.get("inputKey")
if input_key not in input_values:
# key 缺失才回退已保存值;null / 空串 / 0 / false / 空数组都是有效值
continue
value = input_values[input_key]
elif source == "layer":
value = _resolve_layer_value(
binding, pointer, result_rule_key, template_rules, layer_index, input_values, upstream_results
)
else:
raise WorkflowProtocolError(
"TEMPLATE_INVALID",
f"规则 '{result_rule_key}' 的 binding '{pointer}' source 不合法: {source!r}",
resultRuleKey=result_rule_key,
parameterPath=pointer,
)
pointer_set(params, pointer, value, result_rule_key)
return params
def _resolve_layer_value(
binding, pointer, result_rule_key, template_rules, layer_index, input_values, upstream_results
):
layer_id = binding.get("layerId")
property_name = binding.get("property")
node = layer_index.get(layer_id)
if node is None or property_name not in LAYER_PROPERTY_TYPES:
raise WorkflowProtocolError(
"LAYER_REFERENCE_INVALID",
f"规则 '{result_rule_key}' 的 binding '{pointer}' 引用图层无效: {layer_id!r}.{property_name!r}",
resultRuleKey=result_rule_key,
layerId=layer_id,
parameterPath=pointer,
)
if node.get("type") != LAYER_PROPERTY_TYPES[property_name]:
raise WorkflowProtocolError(
"LAYER_REFERENCE_INVALID",
f"规则 '{result_rule_key}' 的 binding '{pointer}' 引用图层类型不匹配: {node.get('type')!r}",
resultRuleKey=result_rule_key,
layerId=layer_id,
parameterPath=pointer,
)
owner_key = None
for rule_key, rule in template_rules.items():
if isinstance(rule, dict) and rule.get("id") == layer_id:
owner_key = rule_key
break
if owner_key is None:
# 未绑定任何规则的图层,直接用模板默认值
return node.get(property_name)
if template_rules[owner_key].get("workflows"):
if owner_key not in upstream_results:
raise WorkflowProtocolError(
"WORKFLOW_EXECUTION_FAILED",
f"规则 '{result_rule_key}' 依赖的上游 AI 结果 '{owner_key}' 尚未生成",
resultRuleKey=result_rule_key,
layerId=layer_id,
parameterPath=pointer,
)
return upstream_results[owner_key]
# 普通素材图层先应用本行值,缺失时回退图层自身值
if owner_key in input_values:
return input_values[owner_key]
return node.get(property_name)
def select_workflow_output(result_data, output, result_type, result_rule_key=None, workflow_key=None):
"""按 output.key + itemIndex 从工具结果中选出最终值。
workflow 未声明 output 描述符(或缺少 key)时,默认取结果容器里的 "output",
兼容 COMFY_APP 结果 outputs.output 的常见约定,避免因缺 output 描述符而整条失败。
"""
output = output if isinstance(output, dict) else {}
output_key = output.get("key") or "output"
item_index = output.get("itemIndex") or 0
items = _locate_output_items(result_data, output_key)
if items is None:
raise WorkflowProtocolError(
"OUTPUT_INVALID",
f"工具结果中找不到输出 '{output_key}'",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
)
if item_index >= len(items):
raise WorkflowProtocolError(
"OUTPUT_INVALID",
f"输出 '{output_key}' 只有 {len(items)} 项,取不到第 {item_index} 项",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
)
value = _extract_scalar(items[item_index], result_type)
if value is None:
raise WorkflowProtocolError(
"OUTPUT_INVALID",
f"输出 '{output_key}' 第 {item_index} 项不是可用的 {result_type} 结果: "
f"{_snippet(items[item_index])}",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
)
if result_type == "image" and not value.startswith(("http://", "https://")):
raise WorkflowProtocolError(
"OUTPUT_INVALID",
f"输出 '{output_key}' 的图片结果不是可访问的 URL: {_snippet(value)}",
resultRuleKey=result_rule_key,
workflowKey=workflow_key,
)
return value
# 结果结构暂未固定,按常见容器逐层查找 output.key
_OUTPUT_CONTAINER_KEYS = ("outputs", "outputNodes", "output", "result", "results", "data")
_ITEM_LIST_KEYS = ("images", "texts", "items", "values", "list", "data", "results")
_IMAGE_VALUE_KEYS = ("url", "imageUrl", "image_url", "fileUrl", "file_url", "ossUrl", "src", "value")
_TEXT_VALUE_KEYS = ("text", "content", "value", "string")
def _locate_output_items(result_data, output_key):
if not isinstance(result_data, dict) or not output_key:
return None
containers = [result_data]
for key in _OUTPUT_CONTAINER_KEYS:
nested = result_data.get(key)
if isinstance(nested, dict):
containers.append(nested)
for inner_key in _OUTPUT_CONTAINER_KEYS:
inner = nested.get(inner_key)
if isinstance(inner, dict):
containers.append(inner)
for container in containers:
if output_key not in container:
continue
return _as_item_list(container[output_key])
return None
def _as_item_list(value):
if isinstance(value, list):
return value
if isinstance(value, dict):
for key in _ITEM_LIST_KEYS:
nested = value.get(key)
if isinstance(nested, list):
return nested
return [value]
if isinstance(value, (str, int, float)) and not isinstance(value, bool):
return [value]
return None
def _extract_scalar(item, result_type):
if isinstance(item, str):
return item or None
if isinstance(item, (int, float)) and not isinstance(item, bool):
return str(item) if result_type == "text" else None
if not isinstance(item, dict):
return None
keys = _IMAGE_VALUE_KEYS if result_type == "image" else _TEXT_VALUE_KEYS
for key in keys:
value = item.get(key)
if isinstance(value, str) and value:
return value
if result_type == "text" and isinstance(value, (int, float)) and not isinstance(value, bool):
return str(value)
return None
def resolve_compose_input(input_values, generated_patch):
"""把 AI 结果合并进行数据,得到交给旧替换/合图链路的 resolvedInput"""
resolved = dict(input_values)
resolved.update(generated_patch)
return resolved
def effective_input_hash(workflow, params):
"""幂等身份的输入部分:工具、版本、解析后参数和输出选择共同决定"""
payload = {
"toolName": workflow.get("toolName"),
"workflowCode": workflow.get("workflowCode"),
"workflowVersion": workflow.get("workflowVersion"),
"params": params,
"output": workflow.get("output"),
}
text = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def remap_rule_layer_ids(template_rules, id_mapping):
"""重新分配图层 ID 后,同步更新 rule.id 与 binding.layerId"""
for rule in template_rules.values():
if not isinstance(rule, dict):
continue
if rule.get("id") in id_mapping:
rule["id"] = id_mapping[rule["id"]]
for workflow in rule.get("workflows") or []:
if not isinstance(workflow, dict):
continue
for binding in (workflow.get("bindings") or {}).values():
if isinstance(binding, dict) and binding.get("layerId") in id_mapping:
binding["layerId"] = id_mapping[binding["layerId"]]
def _snippet(value, limit=200):
try:
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
except Exception:
text = str(value)
return text[:limit] + ("...(已截断)" if len(text) > limit else "")