@miller-tech/uap 1.145.0 → 1.146.1
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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +189 -0
- package/dist/dashboard/server.js.map +1 -1
- package/dist/delivery/user-validation.d.ts.map +1 -1
- package/dist/delivery/user-validation.js +6 -1
- package/dist/delivery/user-validation.js.map +1 -1
- package/dist/memory/embeddings.d.ts.map +1 -1
- package/dist/memory/embeddings.js +4 -2
- package/dist/memory/embeddings.js.map +1 -1
- package/dist/policies/policy-memory.d.ts +29 -0
- package/dist/policies/policy-memory.d.ts.map +1 -1
- package/dist/policies/policy-memory.js +81 -0
- package/dist/policies/policy-memory.js.map +1 -1
- package/dist/policies/policy-order.d.ts +51 -0
- package/dist/policies/policy-order.d.ts.map +1 -0
- package/dist/policies/policy-order.js +124 -0
- package/dist/policies/policy-order.js.map +1 -0
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +127 -2
- package/tools/agents/tests/test_vision_passthrough.py +126 -0
- package/web/dash/styles.css +31 -0
- package/web/dash/tab-policies.js +229 -8
- package/web/dashboard.html +3 -0
|
@@ -1960,6 +1960,60 @@ last_session_id = ""
|
|
|
1960
1960
|
_last_ctx_recheck_ts: float = 0.0
|
|
1961
1961
|
_CTX_RECHECK_INTERVAL: float = 60.0 # Re-detect context window every 60s
|
|
1962
1962
|
|
|
1963
|
+
# ── Vision (multimodal) support ──────────────────────────────────────────────
|
|
1964
|
+
# The upstream llama-server advertises image support in /props
|
|
1965
|
+
# (modalities.vision: true when launched with --mmproj). When available, the
|
|
1966
|
+
# proxy passes Anthropic `image` content blocks through to the model as
|
|
1967
|
+
# OpenAI image_url data-URI parts, so coding agents can visually check their
|
|
1968
|
+
# outputs (screenshots, rendered pages). PROXY_VISION=auto probes and
|
|
1969
|
+
# self-configures per upstream; on/off force it.
|
|
1970
|
+
PROXY_VISION = os.environ.get("PROXY_VISION", "auto").strip().lower()
|
|
1971
|
+
# Rough per-image token cost for context accounting (Qwen-VL-class encoders
|
|
1972
|
+
# land in the hundreds-to-~1k range per image at typical screenshot sizes).
|
|
1973
|
+
PROXY_IMAGE_TOKEN_ESTIMATE = int(os.environ.get("PROXY_IMAGE_TOKEN_ESTIMATE", "800"))
|
|
1974
|
+
upstream_vision: bool = False
|
|
1975
|
+
_last_vision_recheck_ts: float = 0.0
|
|
1976
|
+
_VISION_RECHECK_INTERVAL: float = 60.0
|
|
1977
|
+
|
|
1978
|
+
|
|
1979
|
+
def vision_enabled() -> bool:
|
|
1980
|
+
if PROXY_VISION == "on":
|
|
1981
|
+
return True
|
|
1982
|
+
if PROXY_VISION == "off":
|
|
1983
|
+
return False
|
|
1984
|
+
return upstream_vision
|
|
1985
|
+
|
|
1986
|
+
|
|
1987
|
+
async def _maybe_recheck_vision() -> None:
|
|
1988
|
+
"""Periodically re-probe upstream /props for vision support (auto mode).
|
|
1989
|
+
|
|
1990
|
+
Handles server restarts that add/remove --mmproj mid-session. Non-blocking:
|
|
1991
|
+
skips inside the check interval; failures keep the last known value.
|
|
1992
|
+
"""
|
|
1993
|
+
global upstream_vision, _last_vision_recheck_ts
|
|
1994
|
+
if PROXY_VISION != "auto":
|
|
1995
|
+
return
|
|
1996
|
+
now = time.time()
|
|
1997
|
+
if now - _last_vision_recheck_ts < _VISION_RECHECK_INTERVAL:
|
|
1998
|
+
return
|
|
1999
|
+
_last_vision_recheck_ts = now
|
|
2000
|
+
if http_client is None:
|
|
2001
|
+
return
|
|
2002
|
+
try:
|
|
2003
|
+
props_url = LLAMA_CPP_BASE.replace("/v1", "/props")
|
|
2004
|
+
resp = await http_client.get(props_url, timeout=2.0)
|
|
2005
|
+
if resp.status_code == 200:
|
|
2006
|
+
modalities = (resp.json() or {}).get("modalities") or {}
|
|
2007
|
+
detected = bool(modalities.get("vision"))
|
|
2008
|
+
if detected != upstream_vision:
|
|
2009
|
+
logger.warning(
|
|
2010
|
+
"VISION: upstream modalities.vision=%s — image passthrough %s",
|
|
2011
|
+
detected, "ENABLED" if detected else "DISABLED",
|
|
2012
|
+
)
|
|
2013
|
+
upstream_vision = detected
|
|
2014
|
+
except Exception:
|
|
2015
|
+
pass # Non-critical; retry next interval
|
|
2016
|
+
|
|
1963
2017
|
|
|
1964
2018
|
def _cleanup_stale_monitors(now_ts: float) -> None:
|
|
1965
2019
|
stale = [
|
|
@@ -2087,6 +2141,13 @@ def estimate_message_tokens(msg: dict) -> int:
|
|
|
2087
2141
|
tokens += estimate_tokens(json.dumps(block.get("input", {})))
|
|
2088
2142
|
elif block.get("type") == "tool_result":
|
|
2089
2143
|
tokens += estimate_tokens(_extract_text(block.get("content", "")))
|
|
2144
|
+
inner = block.get("content")
|
|
2145
|
+
if isinstance(inner, list):
|
|
2146
|
+
tokens += PROXY_IMAGE_TOKEN_ESTIMATE * sum(
|
|
2147
|
+
1 for b in inner if isinstance(b, dict) and b.get("type") == "image"
|
|
2148
|
+
)
|
|
2149
|
+
elif block.get("type") == "image":
|
|
2150
|
+
tokens += PROXY_IMAGE_TOKEN_ESTIMATE
|
|
2090
2151
|
return tokens
|
|
2091
2152
|
|
|
2092
2153
|
|
|
@@ -3387,6 +3448,29 @@ async def _shared_secret_auth(request: Request, call_next):
|
|
|
3387
3448
|
# ===========================================================================
|
|
3388
3449
|
|
|
3389
3450
|
|
|
3451
|
+
def _image_block_to_openai(block: dict) -> dict | None:
|
|
3452
|
+
"""Anthropic image block → OpenAI image_url part (data URI or URL)."""
|
|
3453
|
+
src = block.get("source") or {}
|
|
3454
|
+
if src.get("type") == "base64" and src.get("data"):
|
|
3455
|
+
media_type = src.get("media_type", "image/png")
|
|
3456
|
+
return {"type": "image_url", "image_url": {"url": f"data:{media_type};base64,{src['data']}"}}
|
|
3457
|
+
if src.get("type") == "url" and src.get("url"):
|
|
3458
|
+
return {"type": "image_url", "image_url": {"url": src["url"]}}
|
|
3459
|
+
return None
|
|
3460
|
+
|
|
3461
|
+
|
|
3462
|
+
def _extract_images(content) -> list[dict]:
|
|
3463
|
+
"""Collect OpenAI image_url parts nested in Anthropic content."""
|
|
3464
|
+
images: list[dict] = []
|
|
3465
|
+
if isinstance(content, list):
|
|
3466
|
+
for b in content:
|
|
3467
|
+
if isinstance(b, dict) and b.get("type") == "image":
|
|
3468
|
+
part = _image_block_to_openai(b)
|
|
3469
|
+
if part:
|
|
3470
|
+
images.append(part)
|
|
3471
|
+
return images
|
|
3472
|
+
|
|
3473
|
+
|
|
3390
3474
|
def anthropic_to_openai_messages(anthropic_body: dict) -> list[dict]:
|
|
3391
3475
|
"""Convert Anthropic message format to OpenAI message format.
|
|
3392
3476
|
|
|
@@ -3443,11 +3527,22 @@ def anthropic_to_openai_messages(anthropic_body: dict) -> list[dict]:
|
|
|
3443
3527
|
messages.append({"role": role, "content": content})
|
|
3444
3528
|
elif isinstance(content, list):
|
|
3445
3529
|
parts = []
|
|
3530
|
+
image_parts: list[dict] = []
|
|
3446
3531
|
for block in content:
|
|
3447
3532
|
if isinstance(block, str):
|
|
3448
3533
|
parts.append(block)
|
|
3449
3534
|
elif block.get("type") == "text":
|
|
3450
3535
|
parts.append(block.get("text", ""))
|
|
3536
|
+
elif block.get("type") == "image":
|
|
3537
|
+
# Vision passthrough: forward as an OpenAI image_url part
|
|
3538
|
+
# when the upstream model has an mmproj loaded; otherwise
|
|
3539
|
+
# leave an explicit placeholder (silent drops made agents
|
|
3540
|
+
# think the model saw the screenshot).
|
|
3541
|
+
part = _image_block_to_openai(block) if vision_enabled() else None
|
|
3542
|
+
if part:
|
|
3543
|
+
image_parts.append(part)
|
|
3544
|
+
else:
|
|
3545
|
+
parts.append("[image omitted: the serving model has no vision support]")
|
|
3451
3546
|
elif block.get("type") == "thinking":
|
|
3452
3547
|
# Drop thinking blocks from user/assistant content when
|
|
3453
3548
|
# echoed back into history — model shouldn't see them.
|
|
@@ -3480,15 +3575,44 @@ def anthropic_to_openai_messages(anthropic_body: dict) -> list[dict]:
|
|
|
3480
3575
|
tu_id = block.get("tool_use_id", "")
|
|
3481
3576
|
if isinstance(tu_id, str) and tu_id.startswith("toolu_"):
|
|
3482
3577
|
tu_id = tu_id[len("toolu_"):]
|
|
3578
|
+
tr_text = _extract_text(block.get("content", ""))
|
|
3579
|
+
tr_images = _extract_images(block.get("content", "")) if vision_enabled() else []
|
|
3580
|
+
if not vision_enabled():
|
|
3581
|
+
inner = block.get("content")
|
|
3582
|
+
n_imgs = (
|
|
3583
|
+
sum(1 for b in inner if isinstance(b, dict) and b.get("type") == "image")
|
|
3584
|
+
if isinstance(inner, list) else 0
|
|
3585
|
+
)
|
|
3586
|
+
if n_imgs:
|
|
3587
|
+
tr_text = (tr_text + f"\n[{n_imgs} image(s) omitted: the serving model has no vision support]").strip()
|
|
3483
3588
|
messages.append(
|
|
3484
3589
|
{
|
|
3485
3590
|
"role": "tool",
|
|
3486
3591
|
"tool_call_id": tu_id,
|
|
3487
|
-
"content":
|
|
3592
|
+
"content": tr_text,
|
|
3488
3593
|
}
|
|
3489
3594
|
)
|
|
3595
|
+
if tr_images:
|
|
3596
|
+
# OpenAI tool messages are text-only for llama.cpp —
|
|
3597
|
+
# deliver tool-result images as an adjacent user turn
|
|
3598
|
+
# so the model actually SEES them.
|
|
3599
|
+
messages.append(
|
|
3600
|
+
{
|
|
3601
|
+
"role": "user",
|
|
3602
|
+
"content": [
|
|
3603
|
+
{"type": "text", "text": "[image(s) attached from the preceding tool result]"},
|
|
3604
|
+
*tr_images,
|
|
3605
|
+
],
|
|
3606
|
+
}
|
|
3607
|
+
)
|
|
3490
3608
|
continue
|
|
3491
|
-
if
|
|
3609
|
+
if image_parts:
|
|
3610
|
+
typed: list[dict] = []
|
|
3611
|
+
if parts:
|
|
3612
|
+
typed.append({"type": "text", "text": "\n".join(parts)})
|
|
3613
|
+
typed.extend(image_parts)
|
|
3614
|
+
messages.append({"role": role, "content": typed})
|
|
3615
|
+
elif parts:
|
|
3492
3616
|
messages.append({"role": role, "content": "\n".join(parts)})
|
|
3493
3617
|
|
|
3494
3618
|
return messages
|
|
@@ -10175,6 +10299,7 @@ async def messages(request: Request):
|
|
|
10175
10299
|
|
|
10176
10300
|
# Periodically re-detect context window from upstream (handles server restarts)
|
|
10177
10301
|
await _maybe_recheck_context_window()
|
|
10302
|
+
await _maybe_recheck_vision()
|
|
10178
10303
|
|
|
10179
10304
|
if _should_passthrough_model(model):
|
|
10180
10305
|
logger.info("PASSTHROUGH: model=%s -> %s", model, ANTHROPIC_API_BASE)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Vision passthrough: Anthropic image blocks → OpenAI image_url parts.
|
|
3
|
+
|
|
4
|
+
The upstream llama-server advertises vision in /props (modalities.vision) when
|
|
5
|
+
launched with --mmproj; the proxy autodetects and forwards images so coding
|
|
6
|
+
agents can visually check their outputs. Without vision the proxy leaves an
|
|
7
|
+
explicit placeholder instead of silently dropping (agents believed the model
|
|
8
|
+
saw screenshots it never received).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import importlib.util
|
|
12
|
+
import unittest
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_proxy_module():
|
|
17
|
+
proxy_path = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
18
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy", proxy_path)
|
|
19
|
+
mod = importlib.util.module_from_spec(spec)
|
|
20
|
+
spec.loader.exec_module(mod)
|
|
21
|
+
return mod
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
proxy = _load_proxy_module()
|
|
25
|
+
|
|
26
|
+
PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _img_block():
|
|
30
|
+
return {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": PNG_B64}}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class VisionOnTests(unittest.TestCase):
|
|
34
|
+
def setUp(self):
|
|
35
|
+
self._saved = (proxy.PROXY_VISION, proxy.upstream_vision)
|
|
36
|
+
proxy.PROXY_VISION = "auto"
|
|
37
|
+
proxy.upstream_vision = True
|
|
38
|
+
|
|
39
|
+
def tearDown(self):
|
|
40
|
+
proxy.PROXY_VISION, proxy.upstream_vision = self._saved
|
|
41
|
+
|
|
42
|
+
def test_user_image_becomes_typed_multimodal_content(self):
|
|
43
|
+
body = {"messages": [{"role": "user", "content": [
|
|
44
|
+
{"type": "text", "text": "what does this screenshot show?"},
|
|
45
|
+
_img_block(),
|
|
46
|
+
]}]}
|
|
47
|
+
msgs = proxy.anthropic_to_openai_messages(body)
|
|
48
|
+
self.assertEqual(len(msgs), 1)
|
|
49
|
+
content = msgs[0]["content"]
|
|
50
|
+
self.assertIsInstance(content, list)
|
|
51
|
+
kinds = [p["type"] for p in content]
|
|
52
|
+
self.assertEqual(kinds, ["text", "image_url"])
|
|
53
|
+
self.assertTrue(content[1]["image_url"]["url"].startswith("data:image/png;base64,"))
|
|
54
|
+
|
|
55
|
+
def test_url_source_passthrough(self):
|
|
56
|
+
body = {"messages": [{"role": "user", "content": [
|
|
57
|
+
{"type": "image", "source": {"type": "url", "url": "https://x/y.png"}},
|
|
58
|
+
]}]}
|
|
59
|
+
msgs = proxy.anthropic_to_openai_messages(body)
|
|
60
|
+
self.assertEqual(msgs[0]["content"][0]["image_url"]["url"], "https://x/y.png")
|
|
61
|
+
|
|
62
|
+
def test_tool_result_images_delivered_as_adjacent_user_turn(self):
|
|
63
|
+
body = {"messages": [{"role": "user", "content": [
|
|
64
|
+
{"type": "tool_result", "tool_use_id": "toolu_abc", "content": [
|
|
65
|
+
{"type": "text", "text": "screenshot captured"},
|
|
66
|
+
_img_block(),
|
|
67
|
+
]},
|
|
68
|
+
]}]}
|
|
69
|
+
msgs = proxy.anthropic_to_openai_messages(body)
|
|
70
|
+
self.assertEqual(msgs[0]["role"], "tool")
|
|
71
|
+
self.assertIn("screenshot captured", msgs[0]["content"])
|
|
72
|
+
self.assertEqual(msgs[1]["role"], "user")
|
|
73
|
+
parts = msgs[1]["content"]
|
|
74
|
+
self.assertEqual(parts[0]["type"], "text")
|
|
75
|
+
self.assertEqual(parts[1]["type"], "image_url")
|
|
76
|
+
|
|
77
|
+
def test_text_only_messages_stay_plain_strings(self):
|
|
78
|
+
body = {"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]}
|
|
79
|
+
msgs = proxy.anthropic_to_openai_messages(body)
|
|
80
|
+
self.assertEqual(msgs[0]["content"], "hi")
|
|
81
|
+
|
|
82
|
+
def test_image_token_estimate_counts(self):
|
|
83
|
+
msg = {"role": "user", "content": [_img_block(), {"type": "text", "text": "x"}]}
|
|
84
|
+
with_img = proxy.estimate_message_tokens(msg)
|
|
85
|
+
without = proxy.estimate_message_tokens({"role": "user", "content": [{"type": "text", "text": "x"}]})
|
|
86
|
+
self.assertGreaterEqual(with_img - without, proxy.PROXY_IMAGE_TOKEN_ESTIMATE)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class VisionOffTests(unittest.TestCase):
|
|
90
|
+
def setUp(self):
|
|
91
|
+
self._saved = (proxy.PROXY_VISION, proxy.upstream_vision)
|
|
92
|
+
proxy.PROXY_VISION = "auto"
|
|
93
|
+
proxy.upstream_vision = False
|
|
94
|
+
|
|
95
|
+
def tearDown(self):
|
|
96
|
+
proxy.PROXY_VISION, proxy.upstream_vision = self._saved
|
|
97
|
+
|
|
98
|
+
def test_user_image_becomes_explicit_placeholder(self):
|
|
99
|
+
body = {"messages": [{"role": "user", "content": [
|
|
100
|
+
{"type": "text", "text": "see image"}, _img_block(),
|
|
101
|
+
]}]}
|
|
102
|
+
msgs = proxy.anthropic_to_openai_messages(body)
|
|
103
|
+
content = msgs[0]["content"]
|
|
104
|
+
self.assertIsInstance(content, str)
|
|
105
|
+
self.assertIn("image omitted", content)
|
|
106
|
+
|
|
107
|
+
def test_tool_result_image_noted_in_text(self):
|
|
108
|
+
body = {"messages": [{"role": "user", "content": [
|
|
109
|
+
{"type": "tool_result", "tool_use_id": "t1", "content": [
|
|
110
|
+
{"type": "text", "text": "shot"}, _img_block(),
|
|
111
|
+
]},
|
|
112
|
+
]}]}
|
|
113
|
+
msgs = proxy.anthropic_to_openai_messages(body)
|
|
114
|
+
self.assertEqual(len(msgs), 1) # no adjacent user turn without vision
|
|
115
|
+
self.assertIn("1 image(s) omitted", msgs[0]["content"])
|
|
116
|
+
|
|
117
|
+
def test_force_on_overrides_missing_upstream(self):
|
|
118
|
+
proxy.PROXY_VISION = "on"
|
|
119
|
+
self.assertTrue(proxy.vision_enabled())
|
|
120
|
+
proxy.PROXY_VISION = "off"
|
|
121
|
+
proxy.upstream_vision = True
|
|
122
|
+
self.assertFalse(proxy.vision_enabled())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
unittest.main()
|
package/web/dash/styles.css
CHANGED
|
@@ -324,3 +324,34 @@ label.field { display: flex; flex-direction: column; gap: 4px; font-size: 11px;
|
|
|
324
324
|
::-webkit-scrollbar-track { background: var(--bg); }
|
|
325
325
|
::-webkit-scrollbar-thumb { background: var(--bg3); border-radius: 3px; }
|
|
326
326
|
::-webkit-scrollbar-thumb:hover { background: var(--fg3); }
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
/* ── Policy management panel ── */
|
|
330
|
+
.pol-hint { color: var(--fg); opacity: .6; font-size: 12px; margin-bottom: 8px; }
|
|
331
|
+
.pol-toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
|
|
332
|
+
.btn-sm { padding: 2px 8px; font-size: 12px; }
|
|
333
|
+
.btn-on { border-color: var(--green); color: var(--green); }
|
|
334
|
+
.btn-off { border-color: var(--red); color: var(--red); }
|
|
335
|
+
.pol-list { display: flex; flex-direction: column; gap: 6px; }
|
|
336
|
+
.pol-row { display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); }
|
|
337
|
+
.pol-row.off { opacity: .55; }
|
|
338
|
+
.pol-row.dragging { opacity: .4; outline: 1px dashed var(--cyan); }
|
|
339
|
+
.pol-handle { cursor: grab; color: var(--fg); opacity: .4; font-size: 16px; user-select: none; }
|
|
340
|
+
.pol-main { flex: 1; min-width: 0; }
|
|
341
|
+
.pol-name { font-weight: 600; }
|
|
342
|
+
.pol-desc { font-size: 12px; opacity: .65; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
343
|
+
.pol-badges { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
|
|
344
|
+
.pol-badge { font-size: 10px; padding: 1px 6px; border-radius: 999px; border: 1px solid var(--border); text-transform: lowercase; }
|
|
345
|
+
.pol-badge.lvl-required { border-color: var(--red); color: var(--red); }
|
|
346
|
+
.pol-badge.lvl-recommended { border-color: var(--yellow); color: var(--yellow); }
|
|
347
|
+
.pol-badge.lvl-optional { opacity: .6; }
|
|
348
|
+
.pol-badge.on { border-color: var(--green); color: var(--green); }
|
|
349
|
+
.pol-badge.off { border-color: var(--red); color: var(--red); }
|
|
350
|
+
.pol-badge.prio { border-color: var(--cyan); color: var(--cyan); }
|
|
351
|
+
.pol-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
|
352
|
+
.pol-detail .field { display: block; margin: 8px 0; }
|
|
353
|
+
.pol-select { background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: var(--radius); padding: 4px; }
|
|
354
|
+
.pol-section-label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; opacity: .5; margin: 12px 0 4px; }
|
|
355
|
+
.pol-desc-full { font-size: 13px; line-height: 1.5; }
|
|
356
|
+
.pol-prompt { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; font-size: 12px; white-space: pre-wrap; word-break: break-word; max-height: 50vh; overflow: auto; }
|
|
357
|
+
.pol-suggest-list { padding-left: 20px; font-size: 13px; line-height: 1.6; }
|
package/web/dash/tab-policies.js
CHANGED
|
@@ -1,12 +1,233 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* tab-policies.js —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* tab-policies.js — Policy management panel.
|
|
3
|
+
*
|
|
4
|
+
* Lists every policy ordered by firing priority (fires-earliest first) and lets
|
|
5
|
+
* the operator: view each policy's description + full prompt, toggle it, change
|
|
6
|
+
* level/stage, duplicate it, drag-and-drop to reorder (manual), ask for an
|
|
7
|
+
* intelligent order (heuristic + AI refine), dedupe, and import/export the whole
|
|
8
|
+
* set. All DOM is built with UAP.el (no innerHTML), mutations go through UAP.api
|
|
9
|
+
* (token-guarded); GET reads use fetch on UAP.API_URL.
|
|
5
10
|
*/
|
|
11
|
+
(function () {
|
|
12
|
+
'use strict';
|
|
13
|
+
if (typeof UAP === 'undefined' || !UAP.registerTab) return;
|
|
14
|
+
var el = UAP.el, api = UAP.api, toast = UAP.toast;
|
|
6
15
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
16
|
+
function get(path) {
|
|
17
|
+
return fetch(UAP.API_URL + path).then(function (r) {
|
|
18
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
19
|
+
return r.json();
|
|
20
|
+
});
|
|
11
21
|
}
|
|
12
|
-
|
|
22
|
+
|
|
23
|
+
var LEVELS = ['REQUIRED', 'RECOMMENDED', 'OPTIONAL'];
|
|
24
|
+
var STAGES = ['pre-exec', 'always', 'post-exec', 'review'];
|
|
25
|
+
|
|
26
|
+
function badge(text, cls) { return el('span', { class: 'pol-badge ' + (cls || ''), text: text }); }
|
|
27
|
+
|
|
28
|
+
function reloadInto(listEl) {
|
|
29
|
+
UAP.clear(listEl);
|
|
30
|
+
listEl.appendChild(el('div', { class: 'muted', text: 'Loading policies…' }));
|
|
31
|
+
return get('/api/policies').then(function (d) {
|
|
32
|
+
renderList(listEl, (d && d.policies) || []);
|
|
33
|
+
}).catch(function (e) {
|
|
34
|
+
UAP.clear(listEl);
|
|
35
|
+
listEl.appendChild(el('div', { class: 'empty', text: 'Failed to load policies: ' + e.message }));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Drag-and-drop reorder: track the dragged row, reorder DOM on hover, then
|
|
40
|
+
// persist the new id order via /api/policies/reorder on drop.
|
|
41
|
+
function attachDnD(row, listEl) {
|
|
42
|
+
row.setAttribute('draggable', 'true');
|
|
43
|
+
row.addEventListener('dragstart', function (e) {
|
|
44
|
+
row.classList.add('dragging');
|
|
45
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
46
|
+
try { e.dataTransfer.setData('text/plain', row.dataset.id); } catch (_) {}
|
|
47
|
+
});
|
|
48
|
+
row.addEventListener('dragend', function () {
|
|
49
|
+
row.classList.remove('dragging');
|
|
50
|
+
persistOrder(listEl);
|
|
51
|
+
});
|
|
52
|
+
row.addEventListener('dragover', function (e) {
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
var dragging = listEl.querySelector('.pol-row.dragging');
|
|
55
|
+
if (!dragging || dragging === row) return;
|
|
56
|
+
var rect = row.getBoundingClientRect();
|
|
57
|
+
var after = (e.clientY - rect.top) > rect.height / 2;
|
|
58
|
+
listEl.insertBefore(dragging, after ? row.nextSibling : row);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function persistOrder(listEl) {
|
|
63
|
+
var ids = [];
|
|
64
|
+
listEl.querySelectorAll('.pol-row').forEach(function (r) { ids.push(r.dataset.id); });
|
|
65
|
+
api('/api/policies/reorder', { order: ids })
|
|
66
|
+
.then(function () { toast('Order saved', 'ok'); })
|
|
67
|
+
.catch(function () {});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function policyRow(p, listEl) {
|
|
71
|
+
var row = el('div', { class: 'pol-row' + (p.isActive ? '' : ' off') });
|
|
72
|
+
row.dataset.id = p.id;
|
|
73
|
+
var handle = el('span', { class: 'pol-handle', title: 'Drag to reorder' }, '⠿');
|
|
74
|
+
var main = el('div', { class: 'pol-main' },
|
|
75
|
+
el('div', { class: 'pol-name', text: p.name }),
|
|
76
|
+
el('div', { class: 'pol-desc', text: p.description || '(no description)' }),
|
|
77
|
+
el('div', { class: 'pol-badges' },
|
|
78
|
+
badge(p.level, 'lvl-' + String(p.level || '').toLowerCase()),
|
|
79
|
+
badge(p.stage, 'stage'),
|
|
80
|
+
badge(p.category, 'cat'),
|
|
81
|
+
badge('prio ' + p.priority, 'prio')
|
|
82
|
+
)
|
|
83
|
+
);
|
|
84
|
+
var actions = el('div', { class: 'pol-actions' },
|
|
85
|
+
el('button', { class: 'btn btn-sm', title: 'View description + prompt', onclick: function () { openDetail(p.id); } }, 'View'),
|
|
86
|
+
el('button', { class: 'btn btn-sm', title: 'Duplicate', onclick: function () { duplicate(p.id, listEl); } }, 'Duplicate'),
|
|
87
|
+
el('button', {
|
|
88
|
+
class: 'btn btn-sm ' + (p.isActive ? 'btn-on' : 'btn-off'),
|
|
89
|
+
title: p.isActive ? 'Enabled — click to disable' : 'Disabled — click to enable',
|
|
90
|
+
onclick: function () { toggle(p.id, listEl); }
|
|
91
|
+
}, p.isActive ? 'On' : 'Off')
|
|
92
|
+
);
|
|
93
|
+
row.appendChild(handle);
|
|
94
|
+
row.appendChild(main);
|
|
95
|
+
row.appendChild(actions);
|
|
96
|
+
attachDnD(row, listEl);
|
|
97
|
+
return row;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function renderList(listEl, policies) {
|
|
101
|
+
UAP.clear(listEl);
|
|
102
|
+
if (!policies.length) {
|
|
103
|
+
listEl.appendChild(el('div', { class: 'empty', text: 'No policies installed. Import a bundle or run setup.' }));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
policies.forEach(function (p) { listEl.appendChild(policyRow(p, listEl)); });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function toggle(id, listEl) {
|
|
110
|
+
api('/api/policy/' + id + '/toggle').then(function () { reloadInto(listEl); }).catch(function () {});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function duplicate(id, listEl) {
|
|
114
|
+
api('/api/policy/' + id + '/duplicate').then(function () {
|
|
115
|
+
toast('Policy duplicated', 'ok');
|
|
116
|
+
reloadInto(listEl);
|
|
117
|
+
}).catch(function () {});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function openDetail(id) {
|
|
121
|
+
get('/api/policy/' + id).then(function (p) {
|
|
122
|
+
var body = el('div', { class: 'pol-detail' },
|
|
123
|
+
el('div', { class: 'pol-badges' },
|
|
124
|
+
badge(p.level, 'lvl-' + String(p.level || '').toLowerCase()),
|
|
125
|
+
badge(p.stage, 'stage'),
|
|
126
|
+
badge(p.category, 'cat'),
|
|
127
|
+
badge('prio ' + p.priority, 'prio'),
|
|
128
|
+
badge(p.isActive ? 'enabled' : 'disabled', p.isActive ? 'on' : 'off')
|
|
129
|
+
),
|
|
130
|
+
el('label', { class: 'field' }, 'Level',
|
|
131
|
+
selectFor(LEVELS, p.level, function (v) { api('/api/policy/' + id + '/level', { level: v }).then(function () { toast('Level updated', 'ok'); }); })),
|
|
132
|
+
el('label', { class: 'field' }, 'Stage',
|
|
133
|
+
selectFor(STAGES, p.stage, function (v) { api('/api/policy/' + id + '/stage', { stage: v }).then(function () { toast('Stage updated', 'ok'); }); })),
|
|
134
|
+
el('div', { class: 'pol-section-label', text: 'Description' }),
|
|
135
|
+
el('div', { class: 'pol-desc-full', text: p.description || '(none)' }),
|
|
136
|
+
el('div', { class: 'pol-section-label', text: 'Prompt (rawMarkdown)' }),
|
|
137
|
+
el('pre', { class: 'pol-prompt', text: p.rawMarkdown || '' })
|
|
138
|
+
);
|
|
139
|
+
UAP.drawer(p.name, body);
|
|
140
|
+
}).catch(function (e) { toast('Failed to load policy: ' + e.message, 'err'); });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function selectFor(options, current, onChange) {
|
|
144
|
+
var sel = el('select', { class: 'pol-select' });
|
|
145
|
+
options.forEach(function (o) {
|
|
146
|
+
var opt = el('option', { value: o, text: o });
|
|
147
|
+
if (o === current) opt.selected = true;
|
|
148
|
+
sel.appendChild(opt);
|
|
149
|
+
});
|
|
150
|
+
sel.addEventListener('change', function () { onChange(sel.value); });
|
|
151
|
+
return sel;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function suggestOrder(listEl) {
|
|
155
|
+
toast('Computing intelligent order…', 'ok');
|
|
156
|
+
api('/api/policies/suggest-order', { ai: true }).then(function (res) {
|
|
157
|
+
var order = res.order || [];
|
|
158
|
+
var list = el('ol', { class: 'pol-suggest-list' });
|
|
159
|
+
order.forEach(function (o) { list.appendChild(el('li', { text: o.name })); });
|
|
160
|
+
var body = el('div', { class: 'pol-suggest' },
|
|
161
|
+
el('div', { class: 'pol-badges' }, badge(res.source === 'ai' ? 'AI refined' : 'heuristic', res.source === 'ai' ? 'on' : 'cat')),
|
|
162
|
+
el('div', { class: 'pol-section-label', text: 'Rationale' }),
|
|
163
|
+
el('div', { class: 'pol-desc-full', text: res.rationale || '' }),
|
|
164
|
+
el('div', { class: 'pol-section-label', text: 'Proposed firing order (first fires earliest)' }),
|
|
165
|
+
list,
|
|
166
|
+
el('div', { class: 'modal-actions' },
|
|
167
|
+
el('button', { class: 'btn btn-primary', onclick: function () {
|
|
168
|
+
api('/api/policies/reorder', { order: order.map(function (o) { return o.id; }) }).then(function () {
|
|
169
|
+
toast('Applied intelligent order', 'ok');
|
|
170
|
+
UAP.closeDrawer();
|
|
171
|
+
reloadInto(listEl);
|
|
172
|
+
});
|
|
173
|
+
} }, 'Apply this order'))
|
|
174
|
+
);
|
|
175
|
+
UAP.drawer('Intelligent policy order', body);
|
|
176
|
+
}).catch(function (e) { toast('Suggest failed: ' + e.message, 'err'); });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function dedupe(listEl) {
|
|
180
|
+
api('/api/policies/dedupe').then(function (res) {
|
|
181
|
+
toast(res.removed ? ('Removed ' + res.removed + ' duplicate(s)') : 'No duplicates found', 'ok');
|
|
182
|
+
reloadInto(listEl);
|
|
183
|
+
}).catch(function () {});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function exportPolicies() {
|
|
187
|
+
// GET download — navigate to the export endpoint (Content-Disposition attachment).
|
|
188
|
+
var a = el('a', { href: UAP.API_URL + '/api/policies/export', download: 'uap-policies.json' });
|
|
189
|
+
document.body.appendChild(a);
|
|
190
|
+
a.click();
|
|
191
|
+
document.body.removeChild(a);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function importPolicies(listEl) {
|
|
195
|
+
var input = el('input', { type: 'file', accept: '.json,application/json', style: 'display:none' });
|
|
196
|
+
input.addEventListener('change', function () {
|
|
197
|
+
var file = input.files && input.files[0];
|
|
198
|
+
if (!file) return;
|
|
199
|
+
var reader = new FileReader();
|
|
200
|
+
reader.onload = function () {
|
|
201
|
+
var bundle;
|
|
202
|
+
try { bundle = JSON.parse(String(reader.result)); }
|
|
203
|
+
catch (e) { toast('Invalid JSON bundle', 'err'); return; }
|
|
204
|
+
api('/api/policies/import', bundle).then(function (res) {
|
|
205
|
+
toast('Imported ' + (res.imported || 0) + ' policies', 'ok');
|
|
206
|
+
reloadInto(listEl);
|
|
207
|
+
}).catch(function () {});
|
|
208
|
+
};
|
|
209
|
+
reader.readAsText(file);
|
|
210
|
+
});
|
|
211
|
+
document.body.appendChild(input);
|
|
212
|
+
input.click();
|
|
213
|
+
document.body.removeChild(input);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function render(root) {
|
|
217
|
+
UAP.clear(root);
|
|
218
|
+
var listEl = el('div', { class: 'pol-list' });
|
|
219
|
+
var toolbar = el('div', { class: 'pol-toolbar' },
|
|
220
|
+
el('button', { class: 'btn btn-primary', onclick: function () { suggestOrder(listEl); } }, '✨ AI suggest order'),
|
|
221
|
+
el('button', { class: 'btn', onclick: function () { dedupe(listEl); } }, 'Dedupe'),
|
|
222
|
+
el('button', { class: 'btn', onclick: function () { importPolicies(listEl); } }, 'Import'),
|
|
223
|
+
el('button', { class: 'btn', onclick: function () { exportPolicies(); } }, 'Export'),
|
|
224
|
+
el('button', { class: 'btn', onclick: function () { reloadInto(listEl); } }, 'Refresh')
|
|
225
|
+
);
|
|
226
|
+
root.appendChild(el('div', { class: 'pol-hint', text: 'Drag rows to reorder (earlier = fires first). Order minimizes wasted turns: cheap, high-block gates fire first.' }));
|
|
227
|
+
root.appendChild(toolbar);
|
|
228
|
+
root.appendChild(listEl);
|
|
229
|
+
reloadInto(listEl);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
UAP.registerTab('policies', { label: 'Policies', render: render });
|
|
233
|
+
})();
|
package/web/dashboard.html
CHANGED
|
@@ -47,5 +47,8 @@
|
|
|
47
47
|
<script src="/dash/charts.js"></script>
|
|
48
48
|
<script src="/dash/core.js"></script>
|
|
49
49
|
<script src="/dash/tabs.js"></script>
|
|
50
|
+
<!-- Loaded AFTER tabs.js so its registerTab('policies') overrides the inline
|
|
51
|
+
stub with the full policy-management panel (view/duplicate/reorder/AI-order/import/export). -->
|
|
52
|
+
<script src="/dash/tab-policies.js"></script>
|
|
50
53
|
</body>
|
|
51
54
|
</html>
|