xg-cli 1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xg/__init__.py +3 -0
- xg/__main__.py +3 -0
- xg/adaptive/__init__.py +27 -0
- xg/adaptive/calibrate.py +191 -0
- xg/adaptive/feedback.py +170 -0
- xg/adaptive/learned_rules.py +284 -0
- xg/adaptive/signals.py +125 -0
- xg/adaptive/store.py +164 -0
- xg/agent/__init__.py +0 -0
- xg/agent/plan.py +631 -0
- xg/agent/react.py +268 -0
- xg/agent/team.py +1793 -0
- xg/assets/router.lgb +0 -0
- xg/assets/router_semantics.json +21278 -0
- xg/assets/router_semantics.onnx +0 -0
- xg/cli/__init__.py +0 -0
- xg/cli/app.py +1372 -0
- xg/cli/commands.py +921 -0
- xg/cli/completion.py +740 -0
- xg/cli/help.py +185 -0
- xg/cli/train.py +200 -0
- xg/config/__init__.py +0 -0
- xg/config/env_writer.py +121 -0
- xg/config/manager.py +446 -0
- xg/config/mcp.py +229 -0
- xg/config/provider_service.py +267 -0
- xg/config/providers.py +46 -0
- xg/config/settings.py +258 -0
- xg/config/skills.py +100 -0
- xg/config/smart_router_service.py +121 -0
- xg/config/web.py +140 -0
- xg/input_history/__init__.py +7 -0
- xg/input_history/models.py +28 -0
- xg/input_history/persistence.py +126 -0
- xg/input_history/policy.py +39 -0
- xg/input_history/prompt_toolkit.py +36 -0
- xg/input_history/store.py +118 -0
- xg/llm/__init__.py +0 -0
- xg/llm/client.py +49 -0
- xg/llm/factory.py +40 -0
- xg/llm/openai_compat.py +275 -0
- xg/llm/types.py +98 -0
- xg/mcp/__init__.py +4 -0
- xg/mcp/http.py +192 -0
- xg/mcp/manager.py +726 -0
- xg/mcp/models.py +86 -0
- xg/mcp/protocol.py +62 -0
- xg/mcp/resources.py +72 -0
- xg/mcp/schema.py +137 -0
- xg/mcp/stdio.py +210 -0
- xg/mcp/transport.py +66 -0
- xg/memory/__init__.py +15 -0
- xg/memory/context.py +327 -0
- xg/memory/manager.py +111 -0
- xg/memory/models.py +41 -0
- xg/memory/project.py +187 -0
- xg/memory/store.py +144 -0
- xg/router/__init__.py +124 -0
- xg/router/features.py +66 -0
- xg/router/keywords.py +50 -0
- xg/router/ml_router.py +178 -0
- xg/router/model_tiers.py +73 -0
- xg/router/postprocess.py +167 -0
- xg/router/rule_router.py +77 -0
- xg/router/semantic.py +138 -0
- xg/safety/__init__.py +0 -0
- xg/safety/audit.py +96 -0
- xg/safety/guards.py +106 -0
- xg/safety/hitl.py +73 -0
- xg/skill/__init__.py +9 -0
- xg/skill/errors.py +45 -0
- xg/skill/loader.py +42 -0
- xg/skill/models.py +57 -0
- xg/skill/parser.py +93 -0
- xg/skill/policy.py +40 -0
- xg/skill/prompt.py +45 -0
- xg/skill/registry.py +169 -0
- xg/tool/__init__.py +0 -0
- xg/tool/builtin.py +356 -0
- xg/tool/registry.py +228 -0
- xg/tui/__init__.py +34 -0
- xg/tui/app.py +612 -0
- xg/tui/controller.py +1296 -0
- xg/tui/diagrams/__init__.py +22 -0
- xg/tui/diagrams/layout.py +110 -0
- xg/tui/diagrams/markdown.py +39 -0
- xg/tui/diagrams/model.py +36 -0
- xg/tui/diagrams/parser.py +119 -0
- xg/tui/diagrams/renderer.py +551 -0
- xg/tui/i18n.py +169 -0
- xg/tui/messages.py +45 -0
- xg/tui/plan_renderables.py +147 -0
- xg/tui/reducer.py +1029 -0
- xg/tui/renderables.py +252 -0
- xg/tui/state.py +240 -0
- xg/tui/theme.tcss +208 -0
- xg/tui/widgets/__init__.py +1 -0
- xg/tui/widgets/action_card.py +94 -0
- xg/tui/widgets/agent_group_card.py +39 -0
- xg/tui/widgets/approval_modal.py +59 -0
- xg/tui/widgets/collapsible_card.py +40 -0
- xg/tui/widgets/command_suggestions.py +128 -0
- xg/tui/widgets/composer.py +151 -0
- xg/tui/widgets/config_panel.py +198 -0
- xg/tui/widgets/confirm_modal.py +31 -0
- xg/tui/widgets/footer.py +9 -0
- xg/tui/widgets/header.py +118 -0
- xg/tui/widgets/inspector.py +378 -0
- xg/tui/widgets/plan_modal.py +53 -0
- xg/tui/widgets/provider_form.py +141 -0
- xg/tui/widgets/queue_status.py +30 -0
- xg/tui/widgets/smart_router_form.py +95 -0
- xg/tui/widgets/transcript.py +354 -0
- xg/tui/workers.py +14 -0
- xg/web/__init__.py +21 -0
- xg/web/errors.py +57 -0
- xg/web/extract.py +127 -0
- xg/web/fetch.py +106 -0
- xg/web/markdown.py +77 -0
- xg/web/models.py +91 -0
- xg/web/providers.py +79 -0
- xg/web/search.py +118 -0
- xg/web/searxng.py +27 -0
- xg/web/serpapi.py +29 -0
- xg/web/url_policy.py +110 -0
- xg/web/zhipu.py +27 -0
- xg_cli-1.0.dist-info/METADATA +284 -0
- xg_cli-1.0.dist-info/RECORD +130 -0
- xg_cli-1.0.dist-info/WHEEL +4 -0
- xg_cli-1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"""Unicode/ASCII text rendering for a bounded flowchart layout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from rich.cells import cell_len
|
|
8
|
+
|
|
9
|
+
from xg.tui.diagrams.layout import FlowchartLayout, layout_flowchart
|
|
10
|
+
from xg.tui.diagrams.model import FlowchartModel, FlowchartNode
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class DiagramRender:
|
|
15
|
+
text: str
|
|
16
|
+
mode: str = "unicode"
|
|
17
|
+
warnings: tuple[str, ...] = ()
|
|
18
|
+
width: int = 0
|
|
19
|
+
height: int = 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_UNICODE = {
|
|
23
|
+
"h": "─", "v": "│", "tl": "┌", "tr": "┐", "bl": "└", "br": "┘",
|
|
24
|
+
"tee_down": "┬", "tee_up": "┴", "arrow": "▼", "arrow_right": "▶",
|
|
25
|
+
}
|
|
26
|
+
_ASCII = {
|
|
27
|
+
"h": "-", "v": "|", "tl": "+", "tr": "+", "bl": "+", "br": "+",
|
|
28
|
+
"tee_down": "+", "tee_up": "+", "arrow": "v", "arrow_right": ">",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _fit(value: str, width: int) -> str:
|
|
33
|
+
if width <= 1:
|
|
34
|
+
return ""
|
|
35
|
+
value = value.replace("\n", " ").strip()
|
|
36
|
+
if cell_len(value) <= width:
|
|
37
|
+
return value
|
|
38
|
+
result = ""
|
|
39
|
+
used = 0
|
|
40
|
+
for char in value:
|
|
41
|
+
size = cell_len(char)
|
|
42
|
+
if used + size > max(0, width - 1):
|
|
43
|
+
break
|
|
44
|
+
result += char
|
|
45
|
+
used += size
|
|
46
|
+
return result + "…"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _wrap(value: str, width: int) -> list[str]:
|
|
50
|
+
"""Wrap text by terminal cells without dropping CJK or emoji characters."""
|
|
51
|
+
if width <= 0:
|
|
52
|
+
return [""]
|
|
53
|
+
lines: list[str] = []
|
|
54
|
+
for raw_line in value.replace("\r", "").split("\n"):
|
|
55
|
+
current = ""
|
|
56
|
+
used = 0
|
|
57
|
+
for char in raw_line:
|
|
58
|
+
char_width = cell_len(char)
|
|
59
|
+
if char_width > 0 and current and used + char_width > width:
|
|
60
|
+
lines.append(current)
|
|
61
|
+
current = ""
|
|
62
|
+
used = 0
|
|
63
|
+
current += char
|
|
64
|
+
used += char_width
|
|
65
|
+
lines.append(current)
|
|
66
|
+
return lines or [""]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _node_width(node: FlowchartNode, *, unicode: bool) -> int:
|
|
70
|
+
# Keep nodes bounded so long titles wrap instead of forcing the whole
|
|
71
|
+
# diagram beyond the terminal width.
|
|
72
|
+
longest_line = max((cell_len(line) for line in node.label.splitlines()), default=0)
|
|
73
|
+
return max(7, min(38, longest_line) + 4)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _box(node: FlowchartNode, width: int, *, unicode: bool) -> list[str]:
|
|
77
|
+
chars = _UNICODE if unicode else _ASCII
|
|
78
|
+
top = chars["tl"] + chars["h"] * (width - 2) + chars["tr"]
|
|
79
|
+
bottom = chars["bl"] + chars["h"] * (width - 2) + chars["br"]
|
|
80
|
+
middle: list[str] = []
|
|
81
|
+
for label in _wrap(node.label, width - 4):
|
|
82
|
+
padding = max(0, width - 2 - cell_len(label))
|
|
83
|
+
left = padding // 2
|
|
84
|
+
right = padding - left
|
|
85
|
+
middle.append(chars["v"] + " " * left + label + " " * right + chars["v"])
|
|
86
|
+
if node.shape == "round":
|
|
87
|
+
if unicode:
|
|
88
|
+
top = "╭" + "─" * (width - 2) + "╮"
|
|
89
|
+
bottom = "╰" + "─" * (width - 2) + "╯"
|
|
90
|
+
else:
|
|
91
|
+
top = "/" + "-" * (width - 2) + "\\"
|
|
92
|
+
bottom = "\\" + "-" * (width - 2) + "/"
|
|
93
|
+
elif node.shape == "circle":
|
|
94
|
+
if unicode:
|
|
95
|
+
top = "╭" + "─" * (width - 2) + "╮"
|
|
96
|
+
bottom = "╰" + "─" * (width - 2) + "╯"
|
|
97
|
+
else:
|
|
98
|
+
top = "(" + "-" * (width - 2) + ")"
|
|
99
|
+
bottom = "(" + "-" * (width - 2) + ")"
|
|
100
|
+
elif node.shape == "diamond":
|
|
101
|
+
# A compact diamond-like marker keeps the graph readable at narrow
|
|
102
|
+
# widths while avoiding a large, fragile multi-line polygon.
|
|
103
|
+
top = "<" + "-" * (width - 2) + ">" if not unicode else "◇" + "─" * (width - 2) + "◇"
|
|
104
|
+
bottom = top
|
|
105
|
+
return [top, *middle, bottom]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _blank(width: int, height: int) -> list[list[str]]:
|
|
109
|
+
return [[" "] * width for _ in range(height)]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_CONTINUATION = ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _put_text(canvas: list[list[str]], x: int, y: int, value: str) -> None:
|
|
116
|
+
"""Write text using terminal cells rather than Python character indexes."""
|
|
117
|
+
if not (0 <= y < len(canvas)):
|
|
118
|
+
return
|
|
119
|
+
for char in value:
|
|
120
|
+
char_width = cell_len(char)
|
|
121
|
+
if char_width <= 0:
|
|
122
|
+
if x > 0 and x <= len(canvas[y]) and canvas[y][x - 1] not in (" ", _CONTINUATION):
|
|
123
|
+
canvas[y][x - 1] += char
|
|
124
|
+
continue
|
|
125
|
+
if x >= len(canvas[y]):
|
|
126
|
+
break
|
|
127
|
+
if x >= 0:
|
|
128
|
+
canvas[y][x] = char
|
|
129
|
+
for offset in range(1, char_width):
|
|
130
|
+
if x + offset >= len(canvas[y]):
|
|
131
|
+
break
|
|
132
|
+
canvas[y][x + offset] = _CONTINUATION
|
|
133
|
+
x += char_width
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _put(canvas: list[list[str]], x: int, y: int, value: str) -> None:
|
|
137
|
+
"""Write one-cell diagram content without corrupting wide characters."""
|
|
138
|
+
if 0 <= y < len(canvas) and 0 <= x < len(canvas[y]):
|
|
139
|
+
if canvas[y][x] == _CONTINUATION:
|
|
140
|
+
return
|
|
141
|
+
canvas[y][x] = value
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _draw_horizontal(canvas: list[list[str]], y: int, x1: int, x2: int, char: str) -> None:
|
|
145
|
+
if not (0 <= y < len(canvas)):
|
|
146
|
+
return
|
|
147
|
+
start, end = sorted((x1, x2))
|
|
148
|
+
for x in range(start, end + 1):
|
|
149
|
+
_put(canvas, x, y, char)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _merge_edge_char(existing: str, value: str, *, unicode: bool) -> str:
|
|
153
|
+
"""Merge crossing connector characters without destroying either path."""
|
|
154
|
+
if existing in (" ", _CONTINUATION):
|
|
155
|
+
return value
|
|
156
|
+
# An arrow is the semantic endpoint of an edge. It must win over the
|
|
157
|
+
# vertical segment that was drawn immediately before it.
|
|
158
|
+
if value in {"▼", "▶", "v", ">"}:
|
|
159
|
+
return value
|
|
160
|
+
if existing in {"▼", "▶", "v", ">"}:
|
|
161
|
+
return existing
|
|
162
|
+
if existing == value:
|
|
163
|
+
return existing
|
|
164
|
+
if not unicode:
|
|
165
|
+
return "+"
|
|
166
|
+
if {existing, value} <= {"─", "│"}:
|
|
167
|
+
return "┼"
|
|
168
|
+
if existing in {"─", "│", "┼"} and value in {"─", "│", "┼"}:
|
|
169
|
+
return "┼"
|
|
170
|
+
return existing
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _put_edge(
|
|
174
|
+
canvas: list[list[str]],
|
|
175
|
+
x: int,
|
|
176
|
+
y: int,
|
|
177
|
+
value: str,
|
|
178
|
+
*,
|
|
179
|
+
unicode: bool,
|
|
180
|
+
node_regions: list[tuple[int, int, int, int, int]],
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Write a connector while protecting every node rectangle."""
|
|
183
|
+
if not (0 <= y < len(canvas) and 0 <= x < len(canvas[y])):
|
|
184
|
+
return
|
|
185
|
+
if any(left <= x < left + node_width and top <= y < top + node_height
|
|
186
|
+
for left, top, node_width, node_height, _ in node_regions):
|
|
187
|
+
return
|
|
188
|
+
existing = canvas[y][x]
|
|
189
|
+
if existing == _CONTINUATION:
|
|
190
|
+
return
|
|
191
|
+
canvas[y][x] = _merge_edge_char(existing, value, unicode=unicode)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _draw_edge_horizontal(
|
|
195
|
+
canvas: list[list[str]],
|
|
196
|
+
y: int,
|
|
197
|
+
x1: int,
|
|
198
|
+
x2: int,
|
|
199
|
+
char: str,
|
|
200
|
+
*,
|
|
201
|
+
unicode: bool,
|
|
202
|
+
node_regions: list[tuple[int, int, int, int, int]],
|
|
203
|
+
) -> None:
|
|
204
|
+
start, end = sorted((x1, x2))
|
|
205
|
+
for x in range(start, end + 1):
|
|
206
|
+
_put_edge(canvas, x, y, char, unicode=unicode, node_regions=node_regions)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _draw_edge_vertical(
|
|
210
|
+
canvas: list[list[str]],
|
|
211
|
+
x: int,
|
|
212
|
+
y1: int,
|
|
213
|
+
y2: int,
|
|
214
|
+
char: str,
|
|
215
|
+
*,
|
|
216
|
+
unicode: bool,
|
|
217
|
+
node_regions: list[tuple[int, int, int, int, int]],
|
|
218
|
+
) -> None:
|
|
219
|
+
start, end = sorted((y1, y2))
|
|
220
|
+
for y in range(start, end + 1):
|
|
221
|
+
_put_edge(canvas, x, y, char, unicode=unicode, node_regions=node_regions)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _safe_vertical_lanes(
|
|
225
|
+
canvas_width: int,
|
|
226
|
+
*,
|
|
227
|
+
source_rank: int,
|
|
228
|
+
target_rank: int,
|
|
229
|
+
node_regions: list[tuple[int, int, int, int, int]],
|
|
230
|
+
) -> list[int]:
|
|
231
|
+
"""Return x columns that stay outside nodes crossed by a long edge."""
|
|
232
|
+
blocked: set[int] = set()
|
|
233
|
+
for rank in range(source_rank + 1, target_rank):
|
|
234
|
+
for left, _, node_width, _, node_rank in node_regions:
|
|
235
|
+
if node_rank == rank:
|
|
236
|
+
blocked.update(range(left, left + node_width))
|
|
237
|
+
return [x for x in range(canvas_width) if x not in blocked]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _choose_vertical_lane(
|
|
241
|
+
candidates: list[int],
|
|
242
|
+
source_x: int,
|
|
243
|
+
target_x: int,
|
|
244
|
+
) -> int | None:
|
|
245
|
+
if not candidates:
|
|
246
|
+
return None
|
|
247
|
+
# Prefer a lane close to both endpoints, which keeps short branches
|
|
248
|
+
# compact while still allowing long edges to pass around intermediate
|
|
249
|
+
# ranks safely.
|
|
250
|
+
return min(candidates, key=lambda x: (abs(x - source_x) + abs(x - target_x), x))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _canvas_text(canvas: list[list[str]]) -> str:
|
|
254
|
+
lines = []
|
|
255
|
+
for row in canvas:
|
|
256
|
+
# A wide character already contributes its full terminal width. Its
|
|
257
|
+
# continuation cells must not become literal spaces in the output,
|
|
258
|
+
# otherwise ``中文`` would be serialized as ``中 文``.
|
|
259
|
+
lines.append("".join(cell for cell in row if cell != _CONTINUATION).rstrip())
|
|
260
|
+
return "\n".join(lines).rstrip()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _text_dimensions(text: str) -> tuple[int, int]:
|
|
264
|
+
lines = text.splitlines() or [""]
|
|
265
|
+
return max((cell_len(line) for line in lines), default=0), len(lines)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _render_vertical(model: FlowchartModel, layout: FlowchartLayout, *, width: int, unicode: bool) -> str:
|
|
269
|
+
node_by_id = {node.id: node for node in model.nodes}
|
|
270
|
+
gap_x = 5
|
|
271
|
+
widths = {node.id: _node_width(node, unicode=unicode) for node in model.nodes}
|
|
272
|
+
boxes = {node.id: _box(node, widths[node.id], unicode=unicode) for node in model.nodes}
|
|
273
|
+
heights = {node.id: len(boxes[node.id]) for node in model.nodes}
|
|
274
|
+
layer_widths = [sum(widths[n] for n in layer) + gap_x * max(0, len(layer) - 1) for layer in layout.ranks]
|
|
275
|
+
rank_by_node = {
|
|
276
|
+
node_id: rank
|
|
277
|
+
for rank, layer in enumerate(layout.ranks)
|
|
278
|
+
for node_id in layer
|
|
279
|
+
}
|
|
280
|
+
layer_heights = [max((heights[node_id] for node_id in layer), default=3) for layer in layout.ranks]
|
|
281
|
+
adjacent_edges: dict[int, list[tuple[int, object]]] = {}
|
|
282
|
+
for edge_index, edge in enumerate(model.edges):
|
|
283
|
+
source_rank = rank_by_node.get(edge.source)
|
|
284
|
+
target_rank = rank_by_node.get(edge.target)
|
|
285
|
+
if source_rank is not None and target_rank == source_rank + 1:
|
|
286
|
+
adjacent_edges.setdefault(source_rank, []).append((edge_index, edge))
|
|
287
|
+
|
|
288
|
+
# Each direct dependency gets its own horizontal routing row. Without
|
|
289
|
+
# this extra space, all edges between two rounds collapse into one bus.
|
|
290
|
+
layer_gaps = {
|
|
291
|
+
rank: max(2, len(edges) + 1)
|
|
292
|
+
for rank, edges in adjacent_edges.items()
|
|
293
|
+
}
|
|
294
|
+
layer_y = [0]
|
|
295
|
+
for rank in range(max(0, len(layout.ranks) - 1)):
|
|
296
|
+
layer_y.append(layer_y[-1] + layer_heights[rank] + layer_gaps.get(rank, 2))
|
|
297
|
+
|
|
298
|
+
# Reserve a small outer channel for edges which must pass around one or
|
|
299
|
+
# more intermediate ranks. Multiple channels are selected below so
|
|
300
|
+
# long edges remain individually traceable.
|
|
301
|
+
long_edge_count = any(
|
|
302
|
+
edge.source in rank_by_node
|
|
303
|
+
and edge.target in rank_by_node
|
|
304
|
+
and rank_by_node[edge.target] - rank_by_node[edge.source] > 1
|
|
305
|
+
for edge in model.edges
|
|
306
|
+
)
|
|
307
|
+
routing_margin = 2 if long_edge_count else 0
|
|
308
|
+
canvas_width = max(layer_widths or [1]) + routing_margin * 2
|
|
309
|
+
if canvas_width > width:
|
|
310
|
+
raise ValueError("flowchart width exceeded")
|
|
311
|
+
canvas_height = (layer_y[-1] + layer_heights[-1]) if layer_y else 1
|
|
312
|
+
canvas = _blank(canvas_width, max(1, canvas_height))
|
|
313
|
+
positions: dict[str, tuple[int, int]] = {}
|
|
314
|
+
node_regions: list[tuple[int, int, int, int, int]] = []
|
|
315
|
+
for rank, layer in enumerate(layout.ranks):
|
|
316
|
+
current_x = (canvas_width - layer_widths[rank]) // 2
|
|
317
|
+
for node_id in layer:
|
|
318
|
+
node_width = widths[node_id]
|
|
319
|
+
y = layer_y[rank]
|
|
320
|
+
for row, line in enumerate(boxes[node_id]):
|
|
321
|
+
_put_text(canvas, current_x, y + row, line)
|
|
322
|
+
positions[node_id] = (current_x, y)
|
|
323
|
+
node_regions.append((current_x, y, node_width, heights[node_id], rank))
|
|
324
|
+
current_x += node_width + gap_x
|
|
325
|
+
|
|
326
|
+
chars = _UNICODE if unicode else _ASCII
|
|
327
|
+
edge_index_by_identity = {id(edge): edge_index for edge_index, edge in enumerate(model.edges)}
|
|
328
|
+
incoming_edges: dict[str, list[int]] = {}
|
|
329
|
+
for edge_index, edge in enumerate(model.edges):
|
|
330
|
+
if edge.source in positions and edge.target in positions:
|
|
331
|
+
incoming_edges.setdefault(edge.target, []).append(edge_index)
|
|
332
|
+
target_ports: dict[int, int] = {}
|
|
333
|
+
for target, edge_indices in incoming_edges.items():
|
|
334
|
+
target_x, _ = positions[target]
|
|
335
|
+
target_width = widths[target]
|
|
336
|
+
inner_width = max(1, target_width - 2)
|
|
337
|
+
ordered_edges = sorted(
|
|
338
|
+
edge_indices,
|
|
339
|
+
key=lambda edge_index: (
|
|
340
|
+
model.edges[edge_index].source,
|
|
341
|
+
model.edges[edge_index].target,
|
|
342
|
+
edge_index,
|
|
343
|
+
),
|
|
344
|
+
)
|
|
345
|
+
for order, edge_index in enumerate(ordered_edges):
|
|
346
|
+
if len(ordered_edges) == 1:
|
|
347
|
+
offset = inner_width // 2
|
|
348
|
+
else:
|
|
349
|
+
offset = round(order * (inner_width - 1) / (len(ordered_edges) - 1))
|
|
350
|
+
target_ports[edge_index] = target_x + 1 + offset
|
|
351
|
+
|
|
352
|
+
edge_rows: dict[int, dict[int, int]] = {}
|
|
353
|
+
for rank, edges in adjacent_edges.items():
|
|
354
|
+
start = layer_y[rank] + layer_heights[rank]
|
|
355
|
+
end = layer_y[rank + 1] - 1
|
|
356
|
+
rows = list(range(start, end + 1))
|
|
357
|
+
edge_rows[rank] = {
|
|
358
|
+
edge_index: rows[min(order, len(rows) - 1)]
|
|
359
|
+
for order, (edge_index, _) in enumerate(edges)
|
|
360
|
+
}
|
|
361
|
+
used_long_lanes: set[int] = set()
|
|
362
|
+
for edge in sorted(
|
|
363
|
+
model.edges,
|
|
364
|
+
key=lambda item: (
|
|
365
|
+
rank_by_node.get(item.target, 0) - rank_by_node.get(item.source, 0),
|
|
366
|
+
item.source,
|
|
367
|
+
item.target,
|
|
368
|
+
),
|
|
369
|
+
reverse=True,
|
|
370
|
+
):
|
|
371
|
+
if edge.source not in positions or edge.target not in positions:
|
|
372
|
+
continue
|
|
373
|
+
sx, sy = positions[edge.source]
|
|
374
|
+
tx, ty = positions[edge.target]
|
|
375
|
+
source_w = widths[edge.source]
|
|
376
|
+
target_w = widths[edge.target]
|
|
377
|
+
source_h = heights[edge.source]
|
|
378
|
+
edge_index = edge_index_by_identity[id(edge)]
|
|
379
|
+
start_x = sx + source_w // 2
|
|
380
|
+
end_x = target_ports.get(edge_index, tx + target_w // 2)
|
|
381
|
+
start_y, end_y = sy + source_h, ty - 1
|
|
382
|
+
if end_y < start_y:
|
|
383
|
+
continue # backward/cyclic edge: do not overwrite another box
|
|
384
|
+
horizontal_char = "." if edge.style == "dotted" else "=" if edge.style == "thick" else chars["h"]
|
|
385
|
+
vertical_char = "." if edge.style == "dotted" else "=" if edge.style == "thick" else chars["v"]
|
|
386
|
+
source_rank = rank_by_node[edge.source]
|
|
387
|
+
target_rank = rank_by_node[edge.target]
|
|
388
|
+
if target_rank - source_rank <= 1:
|
|
389
|
+
# Route each adjacent edge through its own row in the gap.
|
|
390
|
+
route_y = edge_rows.get(source_rank, {}).get(
|
|
391
|
+
edge_index,
|
|
392
|
+
layer_y[source_rank] + layer_heights[source_rank],
|
|
393
|
+
)
|
|
394
|
+
_draw_edge_vertical(canvas, start_x, start_y, route_y, vertical_char,
|
|
395
|
+
unicode=unicode, node_regions=node_regions)
|
|
396
|
+
_draw_edge_horizontal(canvas, route_y, start_x, end_x, horizontal_char,
|
|
397
|
+
unicode=unicode, node_regions=node_regions)
|
|
398
|
+
_draw_edge_vertical(
|
|
399
|
+
canvas, end_x, route_y, end_y, vertical_char,
|
|
400
|
+
unicode=unicode,
|
|
401
|
+
node_regions=node_regions,
|
|
402
|
+
)
|
|
403
|
+
else:
|
|
404
|
+
lanes = _safe_vertical_lanes(
|
|
405
|
+
canvas_width,
|
|
406
|
+
source_rank=source_rank,
|
|
407
|
+
target_rank=target_rank,
|
|
408
|
+
node_regions=node_regions,
|
|
409
|
+
)
|
|
410
|
+
unused_lanes = [lane for lane in lanes if lane not in used_long_lanes]
|
|
411
|
+
lane = _choose_vertical_lane(unused_lanes or lanes, start_x, end_x)
|
|
412
|
+
if lane is None:
|
|
413
|
+
# This should only occur for an invalid layout with no spare
|
|
414
|
+
# column. Keep the edge out of node cells rather than drawing
|
|
415
|
+
# a misleading line through another task.
|
|
416
|
+
continue
|
|
417
|
+
used_long_lanes.add(lane)
|
|
418
|
+
_draw_edge_horizontal(
|
|
419
|
+
canvas, start_y, start_x, lane, horizontal_char,
|
|
420
|
+
unicode=unicode,
|
|
421
|
+
node_regions=node_regions,
|
|
422
|
+
)
|
|
423
|
+
_draw_edge_vertical(
|
|
424
|
+
canvas, lane, start_y, end_y, vertical_char,
|
|
425
|
+
unicode=unicode,
|
|
426
|
+
node_regions=node_regions,
|
|
427
|
+
)
|
|
428
|
+
_draw_edge_horizontal(
|
|
429
|
+
canvas, end_y, lane, end_x, horizontal_char,
|
|
430
|
+
unicode=unicode,
|
|
431
|
+
node_regions=node_regions,
|
|
432
|
+
)
|
|
433
|
+
_put_edge(
|
|
434
|
+
canvas, end_x, end_y, chars["arrow"],
|
|
435
|
+
unicode=unicode,
|
|
436
|
+
node_regions=node_regions,
|
|
437
|
+
)
|
|
438
|
+
if edge.label:
|
|
439
|
+
label = _fit(edge.label, max(3, abs(end_x - start_x) - 2))
|
|
440
|
+
if label and end_x != start_x and target_rank - source_rank <= 1:
|
|
441
|
+
route_y = edge_rows.get(source_rank, {}).get(edge_index, start_y)
|
|
442
|
+
label_x = min(start_x, end_x) + max(1, (abs(end_x - start_x) - cell_len(label)) // 2)
|
|
443
|
+
_put_text(canvas, label_x, route_y, label)
|
|
444
|
+
return _canvas_text(canvas)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _render_horizontal(model: FlowchartModel, layout: FlowchartLayout, *, width: int, unicode: bool) -> str:
|
|
448
|
+
node_by_id = {node.id: node for node in model.nodes}
|
|
449
|
+
gap_x, gap_y = 7, 2
|
|
450
|
+
widths = {node.id: _node_width(node, unicode=unicode) for node in model.nodes}
|
|
451
|
+
boxes = {node.id: _box(node, widths[node.id], unicode=unicode) for node in model.nodes}
|
|
452
|
+
heights = {node.id: len(boxes[node.id]) for node in model.nodes}
|
|
453
|
+
layer_widths = [max((widths[n] for n in layer), default=7) for layer in layout.ranks]
|
|
454
|
+
canvas_width = sum(layer_widths) + gap_x * max(0, len(layer_widths) - 1)
|
|
455
|
+
if canvas_width > width:
|
|
456
|
+
raise ValueError("flowchart width exceeded")
|
|
457
|
+
layer_heights = [sum(heights[node_id] for node_id in layer) + max(0, len(layer) - 1) * gap_y for layer in layout.ranks]
|
|
458
|
+
canvas_height = max(layer_heights or [3])
|
|
459
|
+
canvas = _blank(canvas_width, canvas_height)
|
|
460
|
+
positions: dict[str, tuple[int, int]] = {}
|
|
461
|
+
x = 0
|
|
462
|
+
for rank, layer in enumerate(layout.ranks):
|
|
463
|
+
layer_height = layer_heights[rank]
|
|
464
|
+
y = (canvas_height - layer_height) // 2
|
|
465
|
+
for node_id in layer:
|
|
466
|
+
node_w = widths[node_id]
|
|
467
|
+
node_x = x + (layer_widths[rank] - node_w) // 2
|
|
468
|
+
for row, line in enumerate(boxes[node_id]):
|
|
469
|
+
_put_text(canvas, node_x, y + row, line)
|
|
470
|
+
positions[node_id] = (node_x, y)
|
|
471
|
+
y += heights[node_id] + gap_y
|
|
472
|
+
x += layer_widths[rank] + gap_x
|
|
473
|
+
|
|
474
|
+
chars = _UNICODE if unicode else _ASCII
|
|
475
|
+
for edge in model.edges:
|
|
476
|
+
if edge.source not in positions or edge.target not in positions:
|
|
477
|
+
continue
|
|
478
|
+
sx, sy = positions[edge.source]
|
|
479
|
+
tx, ty = positions[edge.target]
|
|
480
|
+
start_x, end_x = sx + widths[edge.source], tx - 1
|
|
481
|
+
start_y, end_y = sy + heights[edge.source] // 2, ty + heights[edge.target] // 2
|
|
482
|
+
if end_x < start_x:
|
|
483
|
+
continue
|
|
484
|
+
line_char = "." if edge.style == "dotted" else "=" if edge.style == "thick" else chars["h"]
|
|
485
|
+
if start_y == end_y:
|
|
486
|
+
_draw_horizontal(canvas, start_y, start_x, end_x, line_char)
|
|
487
|
+
else:
|
|
488
|
+
mid_x = start_x + max(0, (end_x - start_x) // 2)
|
|
489
|
+
for x_pos in range(start_x, mid_x + 1):
|
|
490
|
+
_put(canvas, x_pos, start_y, line_char)
|
|
491
|
+
for y_pos in range(min(start_y, end_y), max(start_y, end_y) + 1):
|
|
492
|
+
_put(canvas, mid_x, y_pos, chars["v"] if line_char != "." else ".")
|
|
493
|
+
for x_pos in range(mid_x, end_x + 1):
|
|
494
|
+
_put(canvas, x_pos, end_y, line_char)
|
|
495
|
+
_put(canvas, end_x, end_y, chars["arrow_right"])
|
|
496
|
+
if edge.label:
|
|
497
|
+
label = _fit(edge.label, max(3, end_x - start_x - 2))
|
|
498
|
+
_put_text(canvas, start_x + 1, start_y, label)
|
|
499
|
+
return _canvas_text(canvas)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _structured(model: FlowchartModel) -> str:
|
|
503
|
+
lines = [f"Flowchart {model.direction}"]
|
|
504
|
+
lines.extend(f"节点: {node.id}「{node.label}」" for node in model.nodes)
|
|
505
|
+
lines.extend(
|
|
506
|
+
f"连线: {edge.source} -> {edge.target}" + (f"「{edge.label}」" if edge.label else "")
|
|
507
|
+
for edge in model.edges
|
|
508
|
+
)
|
|
509
|
+
return "\n".join(lines)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def render_flowchart(
|
|
513
|
+
model: FlowchartModel,
|
|
514
|
+
*,
|
|
515
|
+
width: int = 120,
|
|
516
|
+
unicode: bool = True,
|
|
517
|
+
max_width: int = 160,
|
|
518
|
+
max_height: int = 120,
|
|
519
|
+
rank_by_node: dict[str, int] | None = None,
|
|
520
|
+
) -> DiagramRender:
|
|
521
|
+
"""Render a model, following the documented Unicode → ASCII → text order."""
|
|
522
|
+
width = max(1, min(width, max_width))
|
|
523
|
+
layout = layout_flowchart(model, rank_by_node=rank_by_node)
|
|
524
|
+
warnings = list(model.warnings)
|
|
525
|
+
renderers = [(unicode, "unicode"), (False, "ascii")] if unicode else [(False, "ascii")]
|
|
526
|
+
for use_unicode, mode in renderers:
|
|
527
|
+
try:
|
|
528
|
+
text = _render_horizontal(model, layout, width=width, unicode=use_unicode) if model.direction in ("LR", "RL") else _render_vertical(model, layout, width=width, unicode=use_unicode)
|
|
529
|
+
if text.count("\n") + 1 <= max_height:
|
|
530
|
+
if layout.cyclic:
|
|
531
|
+
warnings.append("检测到循环边,已使用有限布局")
|
|
532
|
+
render_width, render_height = _text_dimensions(text)
|
|
533
|
+
return DiagramRender(
|
|
534
|
+
text=text,
|
|
535
|
+
mode=mode,
|
|
536
|
+
warnings=tuple(warnings),
|
|
537
|
+
width=render_width,
|
|
538
|
+
height=render_height,
|
|
539
|
+
)
|
|
540
|
+
except ValueError:
|
|
541
|
+
continue
|
|
542
|
+
warnings.append("图表超出当前终端宽度或高度,已降级为结构化文本")
|
|
543
|
+
text = _structured(model)
|
|
544
|
+
render_width, render_height = _text_dimensions(text)
|
|
545
|
+
return DiagramRender(
|
|
546
|
+
text=text,
|
|
547
|
+
mode="structured",
|
|
548
|
+
warnings=tuple(warnings),
|
|
549
|
+
width=render_width,
|
|
550
|
+
height=render_height,
|
|
551
|
+
)
|