langstage-codraft 0.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.
- langstage_codraft/__init__.py +42 -0
- langstage_codraft/agent.py +301 -0
- langstage_codraft/cli.py +237 -0
- langstage_codraft/config.py +58 -0
- langstage_codraft/middleware.py +133 -0
- langstage_codraft/pages.py +46 -0
- langstage_codraft/render.py +243 -0
- langstage_codraft/server.py +465 -0
- langstage_codraft/static/assets/embed-DBsb0C1n.js +201 -0
- langstage_codraft/static/assets/index-aAs5o0Qg.js +280 -0
- langstage_codraft/static/assets/index-yhQ7mwPb.css +1 -0
- langstage_codraft/static/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
- langstage_codraft/static/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
- langstage_codraft/static/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
- langstage_codraft/static/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
- langstage_codraft/static/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
- langstage_codraft/static/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
- langstage_codraft/static/assets/inter-cyrillic-700-normal-CjBOestx.woff2 +0 -0
- langstage_codraft/static/assets/inter-cyrillic-700-normal-DrXBdSj3.woff +0 -0
- langstage_codraft/static/assets/inter-cyrillic-800-normal-C7MGvYyJ.woff2 +0 -0
- langstage_codraft/static/assets/inter-cyrillic-800-normal-CCHyn08d.woff +0 -0
- langstage_codraft/static/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
- langstage_codraft/static/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
- langstage_codraft/static/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
- langstage_codraft/static/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
- langstage_codraft/static/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
- langstage_codraft/static/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
- langstage_codraft/static/assets/inter-cyrillic-ext-700-normal-BjwYoWNd.woff2 +0 -0
- langstage_codraft/static/assets/inter-cyrillic-ext-700-normal-LO58E6JB.woff +0 -0
- langstage_codraft/static/assets/inter-cyrillic-ext-800-normal-BZOjs1Xv.woff2 +0 -0
- langstage_codraft/static/assets/inter-cyrillic-ext-800-normal-Ca-gJeZY.woff +0 -0
- langstage_codraft/static/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
- langstage_codraft/static/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
- langstage_codraft/static/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
- langstage_codraft/static/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
- langstage_codraft/static/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
- langstage_codraft/static/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
- langstage_codraft/static/assets/inter-greek-700-normal-BUv2fZ6O.woff +0 -0
- langstage_codraft/static/assets/inter-greek-700-normal-C3JjAnD8.woff2 +0 -0
- langstage_codraft/static/assets/inter-greek-800-normal-BU00tryP.woff +0 -0
- langstage_codraft/static/assets/inter-greek-800-normal-CLIouy3y.woff2 +0 -0
- langstage_codraft/static/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
- langstage_codraft/static/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
- langstage_codraft/static/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
- langstage_codraft/static/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
- langstage_codraft/static/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
- langstage_codraft/static/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
- langstage_codraft/static/assets/inter-greek-ext-700-normal-BoQ6DsYi.woff +0 -0
- langstage_codraft/static/assets/inter-greek-ext-700-normal-qfdV9bQt.woff2 +0 -0
- langstage_codraft/static/assets/inter-greek-ext-800-normal-B--PVpEC.woff2 +0 -0
- langstage_codraft/static/assets/inter-greek-ext-800-normal-DUe57HfS.woff +0 -0
- langstage_codraft/static/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- langstage_codraft/static/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- langstage_codraft/static/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- langstage_codraft/static/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- langstage_codraft/static/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- langstage_codraft/static/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- langstage_codraft/static/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
- langstage_codraft/static/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- langstage_codraft/static/assets/inter-latin-800-normal-BYj_oED-.woff2 +0 -0
- langstage_codraft/static/assets/inter-latin-800-normal-D1mf63XC.woff +0 -0
- langstage_codraft/static/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
- langstage_codraft/static/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
- langstage_codraft/static/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
- langstage_codraft/static/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
- langstage_codraft/static/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
- langstage_codraft/static/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
- langstage_codraft/static/assets/inter-latin-ext-700-normal-Ca8adRJv.woff2 +0 -0
- langstage_codraft/static/assets/inter-latin-ext-700-normal-TidjK2hL.woff +0 -0
- langstage_codraft/static/assets/inter-latin-ext-800-normal-BOMpwxm3.woff +0 -0
- langstage_codraft/static/assets/inter-latin-ext-800-normal-DZJjya6U.woff2 +0 -0
- langstage_codraft/static/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
- langstage_codraft/static/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
- langstage_codraft/static/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
- langstage_codraft/static/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
- langstage_codraft/static/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
- langstage_codraft/static/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
- langstage_codraft/static/assets/inter-vietnamese-700-normal-BZaoP0fm.woff +0 -0
- langstage_codraft/static/assets/inter-vietnamese-700-normal-DlLaEgI2.woff2 +0 -0
- langstage_codraft/static/assets/inter-vietnamese-800-normal-Cm7tD1pz.woff2 +0 -0
- langstage_codraft/static/assets/inter-vietnamese-800-normal-DDlpr_Ee.woff +0 -0
- langstage_codraft/static/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- langstage_codraft/static/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
- langstage_codraft/static/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
- langstage_codraft/static/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- langstage_codraft/static/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- langstage_codraft/static/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- langstage_codraft/static/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- langstage_codraft/static/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
- langstage_codraft/static/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-cyrillic-400-italic-BLN19cOa.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-cyrillic-400-italic-C-sRFhvU.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-cyrillic-400-normal-C44pakCn.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-cyrillic-400-normal-DEwCKrdL.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-cyrillic-600-normal-BDP5YDHD.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-cyrillic-600-normal-CHh5eQWW.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-cyrillic-700-normal-BK249n6E.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-cyrillic-700-normal-BmBwwUw2.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-cyrillic-ext-400-italic-Drp6JL_V.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-cyrillic-ext-400-italic-zoDFlEzN.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-cyrillic-ext-400-normal-CWV284fw.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-cyrillic-ext-400-normal-CYkrUebz.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-cyrillic-ext-600-normal-D_E8FDgP.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-cyrillic-ext-600-normal-h2VnbV3V.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-cyrillic-ext-700-normal-B9R79AaY.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-cyrillic-ext-700-normal-K0LivNVE.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-greek-400-italic-C7D4Dq0H.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-greek-400-italic-Ce3U_qGW.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-greek-400-normal-Cw5-AHNN.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-greek-400-normal-D_DO3bho.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-greek-600-normal-DdRkKr-1.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-greek-600-normal-OkzgCkGB.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-greek-700-normal-BrLlvq6D.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-greek-700-normal-BzuEQSqJ.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-latin-400-italic-0j97Jm6B.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-latin-400-italic-DZulBB6l.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-latin-400-normal-C1O10bxN.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-latin-400-normal-DJ5YJwmz.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-latin-600-normal-DeJomR3S.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-latin-600-normal-DouSKlru.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-latin-700-normal-C30JPKlG.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-latin-700-normal-CGGdTIBe.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-latin-ext-400-italic-Cj8BerOQ.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-latin-ext-400-italic-Cttr6ITu.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-latin-ext-400-normal-B5LksYz5.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-latin-ext-400-normal-DKmgyNre.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-latin-ext-600-normal-B2fExS53.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-latin-ext-600-normal-pa-C5Dkf.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-latin-ext-700-normal-D0Q30MFM.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-latin-ext-700-normal-PGcHY9mz.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-vietnamese-400-italic-7zG0SGDK.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-vietnamese-400-italic-B7_QYl3c.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-vietnamese-400-normal-9XEw6Pmr.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-vietnamese-400-normal-DFDfvBrN.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-vietnamese-600-normal-B33FcZBi.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-vietnamese-600-normal-CKUOBVO_.woff +0 -0
- langstage_codraft/static/assets/source-serif-4-vietnamese-700-normal-CAL-i-xy.woff2 +0 -0
- langstage_codraft/static/assets/source-serif-4-vietnamese-700-normal-DTkW1D86.woff +0 -0
- langstage_codraft/static/favicon.svg +1 -0
- langstage_codraft/static/index.html +14 -0
- langstage_codraft/store.py +801 -0
- langstage_codraft/templates.py +143 -0
- langstage_codraft/textutil.py +95 -0
- langstage_codraft/themes.py +95 -0
- langstage_codraft/tools.py +574 -0
- langstage_codraft-0.1.0.dist-info/METADATA +222 -0
- langstage_codraft-0.1.0.dist-info/RECORD +150 -0
- langstage_codraft-0.1.0.dist-info/WHEEL +4 -0
- langstage_codraft-0.1.0.dist-info/entry_points.txt +2 -0
- langstage_codraft-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""langstage-codraft: the document stage for your LangGraph agent.
|
|
2
|
+
|
|
3
|
+
Bring your own agent::
|
|
4
|
+
|
|
5
|
+
from langchain.agents import create_agent
|
|
6
|
+
from langstage_codraft import CodraftMiddleware
|
|
7
|
+
|
|
8
|
+
graph = create_agent("openai:gpt-4.1-mini", middleware=[CodraftMiddleware()])
|
|
9
|
+
|
|
10
|
+
or a factory Codraft calls with its tools and middleware::
|
|
11
|
+
|
|
12
|
+
def make(tools, middleware):
|
|
13
|
+
return create_agent(my_model, tools=[*tools, *my_tools], middleware=middleware)
|
|
14
|
+
|
|
15
|
+
then ``langstage-codraft run --agent my_agent.py:graph`` (or ``:make``).
|
|
16
|
+
"""
|
|
17
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
__version__ = version("langstage-codraft")
|
|
21
|
+
except PackageNotFoundError: # pragma: no cover - source checkout
|
|
22
|
+
__version__ = "0.0.0+local"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def __getattr__(name: str):
|
|
26
|
+
# Lazy so `import langstage_codraft` (and the CLI's --version) stays fast.
|
|
27
|
+
if name == "CODRAFT_TOOLS":
|
|
28
|
+
from .tools import CODRAFT_TOOLS
|
|
29
|
+
|
|
30
|
+
return CODRAFT_TOOLS
|
|
31
|
+
if name == "CodraftMiddleware":
|
|
32
|
+
from .middleware import CodraftMiddleware
|
|
33
|
+
|
|
34
|
+
return CodraftMiddleware
|
|
35
|
+
if name == "CODRAFT_PROMPT":
|
|
36
|
+
from .middleware import CODRAFT_PROMPT
|
|
37
|
+
|
|
38
|
+
return CODRAFT_PROMPT
|
|
39
|
+
raise AttributeError(name)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = ["CODRAFT_PROMPT", "CODRAFT_TOOLS", "CodraftMiddleware", "__version__"]
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Resolve which agent a Codraft server runs.
|
|
2
|
+
|
|
3
|
+
Three ways to bring an agent (``--agent path.py:obj``):
|
|
4
|
+
|
|
5
|
+
* **a compiled graph** that already carries the tools, e.g.
|
|
6
|
+
``create_agent(model, middleware=[CodraftMiddleware()])``. It is used as-is.
|
|
7
|
+
* **a factory** ``def make(tools, middleware): ...``. Codraft calls it with
|
|
8
|
+
``CODRAFT_TOOLS`` and ``[CodraftMiddleware()]``: the deployer builds the graph and
|
|
9
|
+
Codraft adds what this app needs (notes p.4).
|
|
10
|
+
* **a compiled graph without the tools.** It still chats, but it cannot touch the
|
|
11
|
+
document. ``langstage-codraft check`` reports this.
|
|
12
|
+
|
|
13
|
+
Without ``--agent``, a default agent is built from ``--model`` or ``[codraft] model``
|
|
14
|
+
(or the first API key found). ``--demo`` runs a keyless scripted co-author that
|
|
15
|
+
exercises the whole suggestion flow.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import inspect
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import uuid
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from .middleware import CodraftMiddleware
|
|
27
|
+
from .tools import CODRAFT_TOOLS, TOOL_NAMES
|
|
28
|
+
|
|
29
|
+
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
|
|
30
|
+
DEFAULT_OPENROUTER_MODEL = "anthropic/claude-haiku-5.5"
|
|
31
|
+
DEFAULT_OPENAI_MODEL = "gpt-5-mini"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ------------------------------------------------------------------------- models
|
|
35
|
+
def default_model_spec() -> str | None:
|
|
36
|
+
if os.environ.get("OPENROUTER_API_KEY"):
|
|
37
|
+
return f"openrouter:{DEFAULT_OPENROUTER_MODEL}"
|
|
38
|
+
if os.environ.get("OPENAI_API_KEY"):
|
|
39
|
+
return f"openai:{DEFAULT_OPENAI_MODEL}"
|
|
40
|
+
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
41
|
+
return "anthropic:claude-haiku-5-5"
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def make_model(spec: str) -> Any:
|
|
46
|
+
"""``openrouter:<slug>`` or any ``init_chat_model`` spec (``openai:gpt-4.1``…)."""
|
|
47
|
+
if spec.startswith("openrouter:"):
|
|
48
|
+
from langchain_openai import ChatOpenAI
|
|
49
|
+
|
|
50
|
+
key = os.environ.get("OPENROUTER_API_KEY")
|
|
51
|
+
if not key:
|
|
52
|
+
raise RuntimeError("OPENROUTER_API_KEY is not set (needed for an openrouter: model)")
|
|
53
|
+
return ChatOpenAI(model=spec.split(":", 1)[1], base_url=OPENROUTER_BASE, api_key=key,
|
|
54
|
+
temperature=0.3, default_headers={"X-Title": "langstage-codraft"})
|
|
55
|
+
from langchain.chat_models import init_chat_model
|
|
56
|
+
|
|
57
|
+
return init_chat_model(spec)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def research_tools() -> list:
|
|
61
|
+
"""Keyless research helpers for the default agent: fetch a URL, and web search
|
|
62
|
+
when the optional ``ddgs`` package (``[search]`` extra) is installed."""
|
|
63
|
+
from langchain_core.tools import tool
|
|
64
|
+
|
|
65
|
+
@tool
|
|
66
|
+
def fetch_url(url: str, max_chars: int = 6000) -> str:
|
|
67
|
+
"""Fetch a web page and return its readable text (truncated)."""
|
|
68
|
+
import httpx
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
r = httpx.get(url, timeout=20, follow_redirects=True,
|
|
72
|
+
headers={"User-Agent": "Mozilla/5.0 (langstage-codraft)"})
|
|
73
|
+
r.raise_for_status()
|
|
74
|
+
except Exception as exc: # noqa: BLE001
|
|
75
|
+
return f"ERROR fetching {url}: {exc}"
|
|
76
|
+
from .textutil import extract_text
|
|
77
|
+
|
|
78
|
+
text = extract_text("page.html", r.content) if "html" in r.headers.get("content-type", "") else r.text
|
|
79
|
+
return text[: max(500, min(max_chars, 20000))]
|
|
80
|
+
|
|
81
|
+
tools = [fetch_url]
|
|
82
|
+
try:
|
|
83
|
+
from ddgs import DDGS
|
|
84
|
+
|
|
85
|
+
@tool
|
|
86
|
+
def web_search(query: str, max_results: int = 5) -> str:
|
|
87
|
+
"""Search the web. Returns titles, urls and snippets to cite as references."""
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
hits = list(DDGS().text(query, max_results=max(1, min(max_results, 10))))
|
|
91
|
+
except Exception as exc: # noqa: BLE001
|
|
92
|
+
return f"ERROR searching: {exc}"
|
|
93
|
+
return "\n".join(f"- {h.get('title')} — {h.get('href')}\n {h.get('body')}" for h in hits) or "No results."
|
|
94
|
+
|
|
95
|
+
tools.append(web_search)
|
|
96
|
+
except ImportError:
|
|
97
|
+
pass
|
|
98
|
+
return tools
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_default_agent(model_spec: str, *, extra_tools: list | None = None) -> Any:
|
|
102
|
+
from langchain.agents import create_agent
|
|
103
|
+
|
|
104
|
+
return create_agent(
|
|
105
|
+
make_model(model_spec),
|
|
106
|
+
tools=research_tools() + list(extra_tools or []),
|
|
107
|
+
middleware=[CodraftMiddleware()],
|
|
108
|
+
name="codraft",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --------------------------------------------------------------------- inspection
|
|
113
|
+
def graph_tool_names(graph: Any) -> set[str]:
|
|
114
|
+
"""Best-effort: the tool names a compiled graph's tool nodes can call."""
|
|
115
|
+
names: set[str] = set()
|
|
116
|
+
for node in (getattr(graph, "nodes", None) or {}).values():
|
|
117
|
+
for obj in (node, getattr(node, "bound", None), getattr(node, "runnable", None)):
|
|
118
|
+
tbn = getattr(obj, "tools_by_name", None)
|
|
119
|
+
if isinstance(tbn, dict):
|
|
120
|
+
names |= set(tbn)
|
|
121
|
+
return names
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def is_graph(obj: Any) -> bool:
|
|
125
|
+
return hasattr(obj, "aget_state") and hasattr(obj, "nodes")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def resolve_agent_object(obj: Any) -> tuple[Any, str]:
|
|
129
|
+
"""Turn a loaded spec object into (graph, kind)."""
|
|
130
|
+
if is_graph(obj):
|
|
131
|
+
return obj, "graph"
|
|
132
|
+
if callable(obj):
|
|
133
|
+
params = inspect.signature(obj).parameters
|
|
134
|
+
kwargs: dict[str, Any] = {}
|
|
135
|
+
if "tools" in params:
|
|
136
|
+
kwargs["tools"] = list(CODRAFT_TOOLS)
|
|
137
|
+
if "middleware" in params:
|
|
138
|
+
kwargs["middleware"] = [CodraftMiddleware(tools=[])] if "tools" in params else [CodraftMiddleware()]
|
|
139
|
+
g = obj(**kwargs)
|
|
140
|
+
if not is_graph(g):
|
|
141
|
+
raise TypeError(f"agent factory returned {type(g).__name__}, not a compiled LangGraph graph")
|
|
142
|
+
return g, "factory"
|
|
143
|
+
raise TypeError(f"agent spec resolved to {type(obj).__name__}; expected a compiled graph or a factory")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def missing_tools(graph: Any) -> list[str]:
|
|
147
|
+
have = graph_tool_names(graph)
|
|
148
|
+
return sorted(TOOL_NAMES - have)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# --------------------------------------------------------------------- demo agent
|
|
152
|
+
def _last_human_idx(messages: list) -> int:
|
|
153
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
154
|
+
if getattr(messages[i], "type", "") == "human":
|
|
155
|
+
return i
|
|
156
|
+
return -1
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _call(name: str, args: dict) -> dict:
|
|
160
|
+
return {"name": name, "args": args, "id": f"call_{uuid.uuid4().hex[:12]}", "type": "tool_call"}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _demo_revise(block_text: str, comment: str) -> str:
|
|
164
|
+
c = comment.lower()
|
|
165
|
+
text = block_text.strip()
|
|
166
|
+
if any(w in c for w in ("short", "concise", "tighten", "trim")):
|
|
167
|
+
sentences = re.split(r"(?<=[.!?])\s+", text)
|
|
168
|
+
return " ".join(sentences[: max(1, len(sentences) // 2)])
|
|
169
|
+
if any(w in c for w in ("expand", "more detail", "elaborate", "longer")):
|
|
170
|
+
return text + "\n\nThis point deserves more detail: it shapes the reader's next decision, so it is spelled out here."
|
|
171
|
+
if any(w in c for w in ("formal", "professional")):
|
|
172
|
+
return text.replace("can't", "cannot").replace("don't", "do not").replace("it's", "it is")
|
|
173
|
+
return text + f"\n\n*Revised to address: “{comment.strip()}”.*"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def demo_turn(messages: list) -> dict:
|
|
177
|
+
"""The scripted co-author: returns {"content": str, "tool_calls": [...]}.
|
|
178
|
+
|
|
179
|
+
Deterministic and keyless. It follows the same tool path a real model would, so
|
|
180
|
+
the demo exercises the suggestion/approval flow end to end."""
|
|
181
|
+
i = _last_human_idx(messages)
|
|
182
|
+
human = messages[i] if i >= 0 else None
|
|
183
|
+
text = human.content if human is not None and isinstance(human.content, str) else ""
|
|
184
|
+
low = text.lower()
|
|
185
|
+
tool_msgs = [m for m in messages[i + 1:] if getattr(m, "type", "") == "tool"]
|
|
186
|
+
done = {m.name for m in tool_msgs}
|
|
187
|
+
last = tool_msgs[-1] if tool_msgs else None
|
|
188
|
+
|
|
189
|
+
if last is None:
|
|
190
|
+
if "comment" in low:
|
|
191
|
+
return {"content": "Let me look at the open comments, top to bottom.",
|
|
192
|
+
"tool_calls": [_call("list_comments", {})]}
|
|
193
|
+
if any(w in low for w in ("layout", "look at", "view", "pages")):
|
|
194
|
+
return {"content": "", "tool_calls": [_call("view_pages", {})]}
|
|
195
|
+
if "reference" in low or "sources" in low:
|
|
196
|
+
return {"content": "", "tool_calls": [_call("compile_references", {})]}
|
|
197
|
+
if any(low.startswith(w) for w in ("write", "draft", "add")):
|
|
198
|
+
topic = re.sub(r"^(write|draft|add)\b(.*?\b(about|on)\b)?\s*(a|an|the)?\s*", "", text,
|
|
199
|
+
flags=re.IGNORECASE).strip().rstrip(".") or "this topic"
|
|
200
|
+
body = (f"{topic[:1].upper()}{topic[1:]} matters because it changes what the reader can do next. "
|
|
201
|
+
"This draft states the claim, gives one piece of evidence, and closes with the implication.")
|
|
202
|
+
return {"content": "", "tool_calls": [_call("propose_insert", {
|
|
203
|
+
"block_type": "text", "content": body, "note": f"Draft paragraph: {topic[:60]}"})]}
|
|
204
|
+
return {"content": "", "tool_calls": [_call("get_document", {})]}
|
|
205
|
+
|
|
206
|
+
if last.name == "list_comments":
|
|
207
|
+
found = re.findall(r"\[(cmt_\w+)\] \(open\) block \[(blk_\w+)\].*? — \w+: (.*)", last.content)
|
|
208
|
+
if not found:
|
|
209
|
+
return {"content": "There are no open comments on blocks, so there is nothing to address.", "tool_calls": []}
|
|
210
|
+
return {"content": "", "tool_calls": [_call("read_blocks", {"block_ids": [b for _, b, _ in found]})]}
|
|
211
|
+
|
|
212
|
+
if last.name == "read_blocks" and "propose_edit" not in done:
|
|
213
|
+
lc = next((m for m in tool_msgs if m.name == "list_comments"), None)
|
|
214
|
+
found = re.findall(r"\[(cmt_\w+)\] \(open\) block \[(blk_\w+)\].*? — \w+: (.*)", lc.content if lc else "")
|
|
215
|
+
bodies: dict[str, str] = {}
|
|
216
|
+
for chunk in re.split(r"\n\n(?=\[blk_)", last.content):
|
|
217
|
+
m = re.match(r"\[(blk_\w+)\] (\w[\w-]*) [^\n]*\n(.*)", chunk, re.DOTALL)
|
|
218
|
+
if m:
|
|
219
|
+
bodies[m.group(1)] = (m.group(2), re.sub(r"\n ref: .*", "", m.group(3)).strip())
|
|
220
|
+
calls = []
|
|
221
|
+
for cid, bid, body in found:
|
|
222
|
+
btype, current = bodies.get(bid, ("text", ""))
|
|
223
|
+
if btype not in ("text", "callout", "heading"):
|
|
224
|
+
calls.append(_call("reply_to_comment", {
|
|
225
|
+
"comment_id": cid,
|
|
226
|
+
"message": f"The demo agent can only revise text; this is a {btype} block, so there's no tool for it."}))
|
|
227
|
+
continue
|
|
228
|
+
new = current.lstrip("# ").title() if btype == "heading" else _demo_revise(current, body)
|
|
229
|
+
calls.append(_call("propose_edit", {"block_id": bid, "content": new, "comment_id": cid,
|
|
230
|
+
"note": f"Addresses: {body.strip()[:80]}"}))
|
|
231
|
+
calls.append(_call("reply_to_comment", {"comment_id": cid,
|
|
232
|
+
"message": "Proposed a revision; accept or reject it on the block."}))
|
|
233
|
+
return {"content": "", "tool_calls": calls}
|
|
234
|
+
|
|
235
|
+
if last.name in ("propose_edit", "reply_to_comment"):
|
|
236
|
+
n = sum(1 for m in tool_msgs if m.name == "propose_edit")
|
|
237
|
+
return {"content": f"Done. I proposed {n} edit(s), handling the comments from top to bottom. "
|
|
238
|
+
"Each one is waiting on its block for you to accept or reject.", "tool_calls": []}
|
|
239
|
+
if last.name == "propose_insert":
|
|
240
|
+
return {"content": "I drafted a paragraph as a suggestion at the end of the document. Accept it to keep it.",
|
|
241
|
+
"tool_calls": []}
|
|
242
|
+
if last.name == "view_pages":
|
|
243
|
+
return {"content": "I looked at the rendered pages. (The demo agent can't judge images; a vision model "
|
|
244
|
+
"would comment on spacing and overflow here.)", "tool_calls": []}
|
|
245
|
+
if last.name == "compile_references":
|
|
246
|
+
return {"content": "Here are the sources cited so far:\n\n" + last.content, "tool_calls": []}
|
|
247
|
+
if last.name == "get_document":
|
|
248
|
+
n = len(re.findall(r"^\d+\. \[blk_", last.content, re.MULTILINE))
|
|
249
|
+
return {"content": (f"(demo agent) I can see {n} block(s). Try: **address all comments**, "
|
|
250
|
+
"**write a paragraph about <topic>**, **look at the layout**, or **list the references**."),
|
|
251
|
+
"tool_calls": []}
|
|
252
|
+
return {"content": "(demo agent) Done.", "tool_calls": []}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def build_demo_agent() -> Any:
|
|
256
|
+
from langchain.agents import create_agent
|
|
257
|
+
from langchain_core.language_models import BaseChatModel
|
|
258
|
+
from langchain_core.messages import AIMessage
|
|
259
|
+
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
260
|
+
|
|
261
|
+
class DemoCoauthorModel(BaseChatModel):
|
|
262
|
+
"""Keyless scripted model (see :func:`demo_turn`)."""
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def _llm_type(self) -> str:
|
|
266
|
+
return "codraft-demo"
|
|
267
|
+
|
|
268
|
+
def bind_tools(self, tools, **kwargs):
|
|
269
|
+
return self
|
|
270
|
+
|
|
271
|
+
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
|
|
272
|
+
turn = demo_turn(list(messages))
|
|
273
|
+
msg = AIMessage(content=turn["content"], tool_calls=turn["tool_calls"])
|
|
274
|
+
return ChatResult(generations=[ChatGeneration(message=msg)])
|
|
275
|
+
|
|
276
|
+
return create_agent(DemoCoauthorModel(), middleware=[CodraftMiddleware(attach_images=False)],
|
|
277
|
+
name="codraft-demo")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------------- resolution
|
|
281
|
+
def resolve(cfg: Any, *, model: str | None = None) -> tuple[Any, dict]:
|
|
282
|
+
"""(graph, info) for a resolved CodraftConfig. ``info`` feeds ``check`` and the UI."""
|
|
283
|
+
from langstage_core import load_agent_spec
|
|
284
|
+
|
|
285
|
+
if cfg.demo:
|
|
286
|
+
return build_demo_agent(), {"kind": "demo", "label": "Demo co-author (keyless)", "missing_tools": []}
|
|
287
|
+
if cfg.agent_spec:
|
|
288
|
+
obj = load_agent_spec(cfg.agent_spec, base_dir=cfg.toml_dir_for("agent_spec"))
|
|
289
|
+
graph, kind = resolve_agent_object(obj)
|
|
290
|
+
return graph, {"kind": kind, "label": cfg.agent_spec, "missing_tools": missing_tools(graph)}
|
|
291
|
+
spec = model or cfg.model or default_model_spec()
|
|
292
|
+
if not spec:
|
|
293
|
+
return None, {"kind": "none", "label": "No agent configured",
|
|
294
|
+
"missing_tools": sorted(TOOL_NAMES),
|
|
295
|
+
"hint": "Pass --agent path.py:graph, --model provider:model, set OPENROUTER_API_KEY "
|
|
296
|
+
"or OPENAI_API_KEY, or run with --demo."}
|
|
297
|
+
return build_default_agent(spec), {"kind": "default", "label": spec, "missing_tools": []}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def describe_info(info: dict) -> str:
|
|
301
|
+
return json.dumps(info, indent=2)
|
langstage_codraft/cli.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""``langstage-codraft`` command line.
|
|
2
|
+
|
|
3
|
+
langstage-codraft run [--agent path.py:graph | --model provider:model | --demo]
|
|
4
|
+
langstage-codraft check [--live]
|
|
5
|
+
langstage-codraft new "Title" [--template report]
|
|
6
|
+
langstage-codraft list
|
|
7
|
+
langstage-codraft export DOC_ID --format pdf -o out.pdf (needs a running server)
|
|
8
|
+
langstage-codraft install-browser
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
import webbrowser
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .config import CodraftConfig
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _cfg(**overrides) -> CodraftConfig:
|
|
24
|
+
return CodraftConfig.resolve(overrides=overrides)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _ok(msg: str) -> None:
|
|
28
|
+
click.echo(f"[ ok ] {msg}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _warn(msg: str) -> None:
|
|
32
|
+
click.echo(f"[warn] {msg}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _fail(msg: str) -> None:
|
|
36
|
+
click.echo(f"[fail] {msg}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@click.group(invoke_without_command=True)
|
|
40
|
+
@click.version_option(__version__, prog_name="langstage-codraft")
|
|
41
|
+
@click.pass_context
|
|
42
|
+
def main(ctx: click.Context) -> None:
|
|
43
|
+
"""Codraft: the document stage for your LangGraph agent."""
|
|
44
|
+
if ctx.invoked_subcommand is None:
|
|
45
|
+
click.echo(ctx.get_help())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_agent_opts = [
|
|
49
|
+
click.option("-a", "--agent", "agent_spec", help="Agent spec, e.g. my_agent.py:graph (a graph or a factory)."),
|
|
50
|
+
click.option("-m", "--model", help="Model for the default agent, e.g. openrouter:anthropic/claude-haiku-5.5."),
|
|
51
|
+
click.option("--demo", is_flag=True, default=None, help="Run the keyless scripted demo co-author."),
|
|
52
|
+
click.option("--data-dir", type=click.Path(path_type=Path), help="Where documents, uploads and checkpoints live."),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _with_agent_opts(f):
|
|
57
|
+
for opt in reversed(_agent_opts):
|
|
58
|
+
f = opt(f)
|
|
59
|
+
return f
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@main.command()
|
|
63
|
+
@_with_agent_opts
|
|
64
|
+
@click.option("--host", help="Bind address (default localhost).")
|
|
65
|
+
@click.option("-p", "--port", type=int, help="Port (default 8060).")
|
|
66
|
+
@click.option("--approval", type=click.Choice(["required", "auto"]), help="Default approval mode for new documents.")
|
|
67
|
+
@click.option("--no-browser", is_flag=True, help="Don't open a browser tab.")
|
|
68
|
+
@click.option("--show-config", is_flag=True, help="Print the resolved config and exit.")
|
|
69
|
+
def run(agent_spec, model, demo, data_dir, host, port, approval, no_browser, show_config) -> None:
|
|
70
|
+
"""Start the Codraft web app."""
|
|
71
|
+
cfg = _cfg(agent_spec=agent_spec, model=model, demo=demo, data_dir=data_dir, host=host, port=port,
|
|
72
|
+
approval=approval)
|
|
73
|
+
if show_config:
|
|
74
|
+
click.echo(cfg.describe())
|
|
75
|
+
return
|
|
76
|
+
from .agent import resolve
|
|
77
|
+
from .server import create_app
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
graph, info = resolve(cfg)
|
|
81
|
+
except Exception as exc: # noqa: BLE001
|
|
82
|
+
_fail(f"could not load the agent: {type(exc).__name__}: {exc}")
|
|
83
|
+
sys.exit(2)
|
|
84
|
+
if graph is None:
|
|
85
|
+
_warn(info.get("hint", "no agent configured; the editor works, chat is disabled"))
|
|
86
|
+
elif info.get("missing_tools"):
|
|
87
|
+
_warn(f"agent {info['label']} lacks codraft tools ({len(info['missing_tools'])} missing); it can chat "
|
|
88
|
+
"but cannot edit the document. Add CodraftMiddleware() or export a factory(tools, middleware).")
|
|
89
|
+
app = create_app(cfg, graph, info)
|
|
90
|
+
url = f"http://{cfg.host}:{cfg.port}"
|
|
91
|
+
click.echo(f"Codraft {__version__} agent: {info.get('label')} data: {Path(cfg.data_dir).resolve()}")
|
|
92
|
+
click.echo(f"Open {url}")
|
|
93
|
+
if not no_browser:
|
|
94
|
+
import threading
|
|
95
|
+
|
|
96
|
+
threading.Timer(1.2, lambda: webbrowser.open(url)).start()
|
|
97
|
+
import uvicorn
|
|
98
|
+
|
|
99
|
+
uvicorn.run(app, host=cfg.host, port=cfg.port, log_level="warning")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@main.command()
|
|
103
|
+
@_with_agent_opts
|
|
104
|
+
@click.option("--live", is_flag=True, help="Also run one real agent turn (costs a model call).")
|
|
105
|
+
@click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
|
|
106
|
+
def check(agent_spec, model, demo, data_dir, live, as_json) -> None:
|
|
107
|
+
"""Preflight: agent, tools, browser for export/vision, and (with --live) one real turn."""
|
|
108
|
+
cfg = _cfg(agent_spec=agent_spec, model=model, demo=demo, data_dir=data_dir)
|
|
109
|
+
from .agent import resolve
|
|
110
|
+
|
|
111
|
+
report: dict = {"version": __version__, "checks": []}
|
|
112
|
+
|
|
113
|
+
def add(name: str, ok: bool | None, detail: str) -> None:
|
|
114
|
+
report["checks"].append({"name": name, "ok": ok, "detail": detail})
|
|
115
|
+
|
|
116
|
+
graph = None
|
|
117
|
+
try:
|
|
118
|
+
graph, info = resolve(cfg)
|
|
119
|
+
if graph is None:
|
|
120
|
+
add("agent", False, info.get("hint", "no agent"))
|
|
121
|
+
else:
|
|
122
|
+
add("agent", True, f"{info['kind']}: {info['label']}")
|
|
123
|
+
missing = info.get("missing_tools") or []
|
|
124
|
+
add("tools", not missing, "all codraft tools present" if not missing
|
|
125
|
+
else f"missing {', '.join(missing)} (add CodraftMiddleware() or use a factory)")
|
|
126
|
+
add("checkpointer", True, "compiled-in" if getattr(graph, "checkpointer", None)
|
|
127
|
+
else "none compiled in; the server attaches a durable SQLite one")
|
|
128
|
+
except Exception as exc: # noqa: BLE001
|
|
129
|
+
add("agent", False, f"{type(exc).__name__}: {exc}")
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
from playwright.sync_api import sync_playwright
|
|
133
|
+
|
|
134
|
+
with sync_playwright() as p:
|
|
135
|
+
b = p.chromium.launch()
|
|
136
|
+
b.close()
|
|
137
|
+
add("browser", True, "Chromium available for export and view_pages")
|
|
138
|
+
except Exception as exc: # noqa: BLE001
|
|
139
|
+
msg = str(exc).splitlines()[0][:160]
|
|
140
|
+
add("browser", None, f"not available ({msg}); run: langstage-codraft install-browser")
|
|
141
|
+
|
|
142
|
+
static = Path(__file__).parent / "static" / "index.html"
|
|
143
|
+
add("web-ui", static.is_file(), "bundled" if static.is_file() else "not built (cd frontend && npm run build)")
|
|
144
|
+
|
|
145
|
+
if live and graph is not None:
|
|
146
|
+
from langstage_core.agui import verify
|
|
147
|
+
|
|
148
|
+
from .store import Store, set_store
|
|
149
|
+
|
|
150
|
+
tmp = Path(cfg.data_dir) / "check"
|
|
151
|
+
store = Store(tmp / "check.db")
|
|
152
|
+
set_store(store)
|
|
153
|
+
doc = store.create_document("check", {})
|
|
154
|
+
res = verify(graph, message="Call get_document, then say OK.", thread_id=doc["id"])
|
|
155
|
+
add("live-turn", bool(res.ok), res.reason)
|
|
156
|
+
|
|
157
|
+
ok = all(c["ok"] is not False for c in report["checks"])
|
|
158
|
+
report["ok"] = ok
|
|
159
|
+
if as_json:
|
|
160
|
+
click.echo(json.dumps(report, indent=2))
|
|
161
|
+
else:
|
|
162
|
+
for c in report["checks"]:
|
|
163
|
+
(_ok if c["ok"] else _warn if c["ok"] is None else _fail)(f"{c['name']}: {c['detail']}")
|
|
164
|
+
sys.exit(0 if ok else 1)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@main.command("list")
|
|
168
|
+
@click.option("--data-dir", type=click.Path(path_type=Path))
|
|
169
|
+
@click.option("--json", "as_json", is_flag=True)
|
|
170
|
+
def list_docs(data_dir, as_json) -> None:
|
|
171
|
+
"""List documents in the data directory."""
|
|
172
|
+
from .store import Store
|
|
173
|
+
|
|
174
|
+
cfg = _cfg(data_dir=data_dir)
|
|
175
|
+
docs = Store(Path(cfg.data_dir) / "codraft.db").list_documents()
|
|
176
|
+
if as_json:
|
|
177
|
+
click.echo(json.dumps(docs, indent=2))
|
|
178
|
+
return
|
|
179
|
+
if not docs:
|
|
180
|
+
click.echo("No documents yet.")
|
|
181
|
+
for d in docs:
|
|
182
|
+
click.echo(f"{d['id']} {d['title']} ({d['n_blocks']} blocks)")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@main.command()
|
|
186
|
+
@click.argument("title")
|
|
187
|
+
@click.option("--template", default="blank", help="Template id (see the new-document dialog).")
|
|
188
|
+
@click.option("--data-dir", type=click.Path(path_type=Path))
|
|
189
|
+
def new(title, template, data_dir) -> None:
|
|
190
|
+
"""Create a document from a template."""
|
|
191
|
+
from . import templates as tpl
|
|
192
|
+
from .store import Store
|
|
193
|
+
|
|
194
|
+
cfg = _cfg(data_dir=data_dir)
|
|
195
|
+
tpl.configure(cfg.templates_dir)
|
|
196
|
+
t = tpl.all_templates().get(template)
|
|
197
|
+
if t is None:
|
|
198
|
+
_fail(f"unknown template {template!r}; available: {', '.join(tpl.all_templates())}")
|
|
199
|
+
sys.exit(2)
|
|
200
|
+
store = Store(Path(cfg.data_dir) / "codraft.db")
|
|
201
|
+
doc = store.create_document(title, {"approval": cfg.approval, "theme": cfg.default_theme,
|
|
202
|
+
**t.get("settings", {})}, t.get("blocks", []))
|
|
203
|
+
click.echo(doc["id"])
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@main.command()
|
|
207
|
+
@click.argument("doc_id")
|
|
208
|
+
@click.option("-f", "--format", "fmt", type=click.Choice(["pdf", "png", "jpg", "html"]), default="pdf")
|
|
209
|
+
@click.option("-o", "--output", type=click.Path(path_type=Path))
|
|
210
|
+
@click.option("--url", default=None, help="Server URL (default http://localhost:<port>).")
|
|
211
|
+
def export(doc_id, fmt, output, url) -> None:
|
|
212
|
+
"""Export a document through a running Codraft server."""
|
|
213
|
+
import httpx
|
|
214
|
+
|
|
215
|
+
cfg = _cfg()
|
|
216
|
+
base = url or f"http://{cfg.host}:{cfg.port}"
|
|
217
|
+
r = httpx.get(f"{base}/api/docs/{doc_id}/export", params={"format": fmt}, timeout=180)
|
|
218
|
+
if r.status_code != 200:
|
|
219
|
+
_fail(f"{r.status_code}: {r.text[:300]}")
|
|
220
|
+
sys.exit(1)
|
|
221
|
+
disp = r.headers.get("content-disposition", "")
|
|
222
|
+
name = disp.split("filename=")[-1].strip('"') if "filename=" in disp else f"{doc_id}.{fmt}"
|
|
223
|
+
out = output or Path(name)
|
|
224
|
+
out.write_bytes(r.content)
|
|
225
|
+
_ok(f"wrote {out} ({len(r.content)} bytes)")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@main.command("install-browser")
|
|
229
|
+
def install_browser_cmd() -> None:
|
|
230
|
+
"""Download Chromium for export and the agent's view_pages tool."""
|
|
231
|
+
from .render import install_browser
|
|
232
|
+
|
|
233
|
+
sys.exit(install_browser())
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__": # pragma: no cover
|
|
237
|
+
main()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Codraft configuration: the family's ``HostConfig`` chain plus a ``[codraft]`` table.
|
|
2
|
+
|
|
3
|
+
Precedence (lowest to highest): defaults < ``langstage.toml`` < ``LANGSTAGE_*`` env <
|
|
4
|
+
CLI flags, exactly as on every other LangStage surface.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, ClassVar
|
|
11
|
+
|
|
12
|
+
from langstage_core import HostConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _env_bool(v: str) -> bool:
|
|
16
|
+
return v.strip().lower() in ("1", "true", "yes", "on")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class CodraftConfig(HostConfig):
|
|
21
|
+
port: int = 8060
|
|
22
|
+
title: str = "Codraft"
|
|
23
|
+
model: str | None = None # e.g. "openrouter:anthropic/claude-haiku-5.5", "openai:gpt-4.1-mini"
|
|
24
|
+
approval: str = "required" # default approval mode for new documents
|
|
25
|
+
data_dir: Path = Path(".codraft") # SQLite store, uploads, checkpoints, renders
|
|
26
|
+
templates_dir: Path | None = None
|
|
27
|
+
default_theme: str = "classic"
|
|
28
|
+
demo: bool = False
|
|
29
|
+
|
|
30
|
+
_ENV: ClassVar[dict] = {
|
|
31
|
+
"model": ("LANGSTAGE_CODRAFT_MODEL", str),
|
|
32
|
+
"approval": ("LANGSTAGE_CODRAFT_APPROVAL", str),
|
|
33
|
+
"data_dir": ("LANGSTAGE_CODRAFT_DATA_DIR", Path),
|
|
34
|
+
"templates_dir": ("LANGSTAGE_CODRAFT_TEMPLATES_DIR", Path),
|
|
35
|
+
"default_theme": ("LANGSTAGE_CODRAFT_THEME", str),
|
|
36
|
+
"demo": ("LANGSTAGE_CODRAFT_DEMO", _env_bool),
|
|
37
|
+
}
|
|
38
|
+
_TOML: ClassVar[dict] = {
|
|
39
|
+
"model": "codraft.model",
|
|
40
|
+
"approval": "codraft.approval",
|
|
41
|
+
"data_dir": "codraft.data_dir",
|
|
42
|
+
"templates_dir": "codraft.templates_dir",
|
|
43
|
+
"default_theme": "codraft.default_theme",
|
|
44
|
+
"demo": "codraft.demo",
|
|
45
|
+
}
|
|
46
|
+
_TOML_PATHS: ClassVar[dict] = {"data_dir": "path", "templates_dir": "path"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def codraft_toml_table() -> dict[str, Any]:
|
|
50
|
+
"""The raw ``[codraft]`` table (themes and the allowed list live there)."""
|
|
51
|
+
try:
|
|
52
|
+
from langstage_core.host.config import load_toml_config
|
|
53
|
+
|
|
54
|
+
data, _ = load_toml_config()
|
|
55
|
+
except Exception: # noqa: BLE001 - a malformed TOML is reported by describe()
|
|
56
|
+
return {}
|
|
57
|
+
t = data.get("codraft", {})
|
|
58
|
+
return t if isinstance(t, dict) else {}
|