chatify-app 0.2.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.
Files changed (240) hide show
  1. chatify/__init__.py +2 -0
  2. chatify/__main__.py +2 -0
  3. chatify/accounts.py +95 -0
  4. chatify/actions.py +31 -0
  5. chatify/apipicks.py +45 -0
  6. chatify/appbuilder.py +260 -0
  7. chatify/budget.py +74 -0
  8. chatify/builddrafts.py +56 -0
  9. chatify/buildimages.py +34 -0
  10. chatify/buildjobs.py +263 -0
  11. chatify/bundle_review.py +39 -0
  12. chatify/cli.py +146 -0
  13. chatify/configchange.py +46 -0
  14. chatify/configdoctor.py +107 -0
  15. chatify/connectors/__init__.py +6 -0
  16. chatify/connectors/arxiv.py +37 -0
  17. chatify/connectors/base.py +45 -0
  18. chatify/connectors/bounded.py +44 -0
  19. chatify/connectors/http_json.py +66 -0
  20. chatify/connectors/local_files.py +198 -0
  21. chatify/connectors/open_sources.py +114 -0
  22. chatify/connectors/pubmed.py +60 -0
  23. chatify/connectors/recovery.py +34 -0
  24. chatify/connectors/web_search.py +43 -0
  25. chatify/draftaudit.py +20 -0
  26. chatify/editstate.py +64 -0
  27. chatify/exports.py +110 -0
  28. chatify/fetch.py +75 -0
  29. chatify/fileread.py +59 -0
  30. chatify/generator_design.py +94 -0
  31. chatify/generators.py +455 -0
  32. chatify/hardware.py +265 -0
  33. chatify/keybundle.py +45 -0
  34. chatify/launcher.py +46 -0
  35. chatify/management.py +161 -0
  36. chatify/modelcatalog.py +28 -0
  37. chatify/mysqlstore.py +88 -0
  38. chatify/nerdload.py +179 -0
  39. chatify/ollama_setup.py +138 -0
  40. chatify/pdfread.py +97 -0
  41. chatify/permissions.py +21 -0
  42. chatify/pipeline.py +863 -0
  43. chatify/portable.py +151 -0
  44. chatify/prompts.py +138 -0
  45. chatify/providers.py +482 -0
  46. chatify/readiness.py +33 -0
  47. chatify/schema.py +232 -0
  48. chatify/secrets.py +64 -0
  49. chatify/server.py +879 -0
  50. chatify/settingsguide.py +65 -0
  51. chatify/source_credentials.py +33 -0
  52. chatify/stage_contracts.py +24 -0
  53. chatify/store.py +412 -0
  54. chatify/suggestions.py +47 -0
  55. chatify/templates/minimal/app.yaml +16 -0
  56. chatify/templates/minimal/docs/about.md +7 -0
  57. chatify/terminal.py +15 -0
  58. chatify/theme.py +155 -0
  59. chatify/web/about.css +35 -0
  60. chatify/web/about.html +1 -0
  61. chatify/web/about.js +16 -0
  62. chatify/web/app.js +914 -0
  63. chatify/web/appbuilder.js +93 -0
  64. chatify/web/appearanceextras.js +13 -0
  65. chatify/web/brand.js +37 -0
  66. chatify/web/builderstudio.css +198 -0
  67. chatify/web/codeeditor.LICENSE.txt +21 -0
  68. chatify/web/codeeditor.js +14 -0
  69. chatify/web/components.css +151 -0
  70. chatify/web/configstate.js +30 -0
  71. chatify/web/customselect.css +26 -0
  72. chatify/web/customselect.js +12 -0
  73. chatify/web/emoji/1f389.png +0 -0
  74. chatify/web/emoji/1f389.webp +0 -0
  75. chatify/web/emoji/1f44b.png +0 -0
  76. chatify/web/emoji/1f44b.webp +0 -0
  77. chatify/web/emoji/1f44d.png +0 -0
  78. chatify/web/emoji/1f44d.webp +0 -0
  79. chatify/web/emoji/1f44f.png +0 -0
  80. chatify/web/emoji/1f44f.webp +0 -0
  81. chatify/web/emoji/1f525.png +0 -0
  82. chatify/web/emoji/1f525.webp +0 -0
  83. chatify/web/emoji/1f600.png +0 -0
  84. chatify/web/emoji/1f600.webp +0 -0
  85. chatify/web/emoji/1f602.png +0 -0
  86. chatify/web/emoji/1f602.webp +0 -0
  87. chatify/web/emoji/1f60a.png +0 -0
  88. chatify/web/emoji/1f60a.webp +0 -0
  89. chatify/web/emoji/1f60d.png +0 -0
  90. chatify/web/emoji/1f60d.webp +0 -0
  91. chatify/web/emoji/1f60e.png +0 -0
  92. chatify/web/emoji/1f60e.webp +0 -0
  93. chatify/web/emoji/1f62d.png +0 -0
  94. chatify/web/emoji/1f62d.webp +0 -0
  95. chatify/web/emoji/1f64c.png +0 -0
  96. chatify/web/emoji/1f64c.webp +0 -0
  97. chatify/web/emoji/1f680.png +0 -0
  98. chatify/web/emoji/1f680.webp +0 -0
  99. chatify/web/emoji/1f914.png +0 -0
  100. chatify/web/emoji/1f914.webp +0 -0
  101. chatify/web/emoji/1f916.png +0 -0
  102. chatify/web/emoji/1f916.webp +0 -0
  103. chatify/web/emoji/1f973.png +0 -0
  104. chatify/web/emoji/1f973.webp +0 -0
  105. chatify/web/emoji/2728.png +0 -0
  106. chatify/web/emoji/2728.webp +0 -0
  107. chatify/web/emoji/ATTRIBUTION.txt +88 -0
  108. chatify/web/emoji/STATIC-NOTO-LICENSE.txt +94 -0
  109. chatify/web/emoji/static-1f308.png +0 -0
  110. chatify/web/emoji/static-1f319.png +0 -0
  111. chatify/web/emoji/static-1f331.png +0 -0
  112. chatify/web/emoji/static-1f33b.png +0 -0
  113. chatify/web/emoji/static-1f34e.png +0 -0
  114. chatify/web/emoji/static-1f354.png +0 -0
  115. chatify/web/emoji/static-1f355.png +0 -0
  116. chatify/web/emoji/static-1f36a.png +0 -0
  117. chatify/web/emoji/static-1f375.png +0 -0
  118. chatify/web/emoji/static-1f382.png +0 -0
  119. chatify/web/emoji/static-1f389.png +0 -0
  120. chatify/web/emoji/static-1f38a.png +0 -0
  121. chatify/web/emoji/static-1f3af.png +0 -0
  122. chatify/web/emoji/static-1f3c6.png +0 -0
  123. chatify/web/emoji/static-1f431.png +0 -0
  124. chatify/web/emoji/static-1f436.png +0 -0
  125. chatify/web/emoji/static-1f438.png +0 -0
  126. chatify/web/emoji/static-1f43c.png +0 -0
  127. chatify/web/emoji/static-1f44b.png +0 -0
  128. chatify/web/emoji/static-1f44d.png +0 -0
  129. chatify/web/emoji/static-1f44e.png +0 -0
  130. chatify/web/emoji/static-1f44f.png +0 -0
  131. chatify/web/emoji/static-1f49a.png +0 -0
  132. chatify/web/emoji/static-1f49b.png +0 -0
  133. chatify/web/emoji/static-1f49c.png +0 -0
  134. chatify/web/emoji/static-1f4a1.png +0 -0
  135. chatify/web/emoji/static-1f4aa.png +0 -0
  136. chatify/web/emoji/static-1f4ac.png +0 -0
  137. chatify/web/emoji/static-1f4c4.png +0 -0
  138. chatify/web/emoji/static-1f4ce.png +0 -0
  139. chatify/web/emoji/static-1f4da.png +0 -0
  140. chatify/web/emoji/static-1f4f0.png +0 -0
  141. chatify/web/emoji/static-1f50d.png +0 -0
  142. chatify/web/emoji/static-1f50e.png +0 -0
  143. chatify/web/emoji/static-1f525.png +0 -0
  144. chatify/web/emoji/static-1f57a.png +0 -0
  145. chatify/web/emoji/static-1f600.png +0 -0
  146. chatify/web/emoji/static-1f601.png +0 -0
  147. chatify/web/emoji/static-1f602.png +0 -0
  148. chatify/web/emoji/static-1f603.png +0 -0
  149. chatify/web/emoji/static-1f604.png +0 -0
  150. chatify/web/emoji/static-1f60a.png +0 -0
  151. chatify/web/emoji/static-1f60c.png +0 -0
  152. chatify/web/emoji/static-1f60d.png +0 -0
  153. chatify/web/emoji/static-1f60e.png +0 -0
  154. chatify/web/emoji/static-1f62d.png +0 -0
  155. chatify/web/emoji/static-1f62e.png +0 -0
  156. chatify/web/emoji/static-1f634.png +0 -0
  157. chatify/web/emoji/static-1f642.png +0 -0
  158. chatify/web/emoji/static-1f64c.png +0 -0
  159. chatify/web/emoji/static-1f64f.png +0 -0
  160. chatify/web/emoji/static-1f680.png +0 -0
  161. chatify/web/emoji/static-1f913.png +0 -0
  162. chatify/web/emoji/static-1f914.png +0 -0
  163. chatify/web/emoji/static-1f916.png +0 -0
  164. chatify/web/emoji/static-1f917.png +0 -0
  165. chatify/web/emoji/static-1f91d.png +0 -0
  166. chatify/web/emoji/static-1f92f.png +0 -0
  167. chatify/web/emoji/static-1f951.png +0 -0
  168. chatify/web/emoji/static-1f970.png +0 -0
  169. chatify/web/emoji/static-1f973.png +0 -0
  170. chatify/web/emoji/static-1f979.png +0 -0
  171. chatify/web/emoji/static-1f98a.png +0 -0
  172. chatify/web/emoji/static-1f98b.png +0 -0
  173. chatify/web/emoji/static-1f9d1-200d-1f4bb.png +0 -0
  174. chatify/web/emoji/static-1f9d1-200d-1f680.png +0 -0
  175. chatify/web/emoji/static-1fae1.png +0 -0
  176. chatify/web/emoji/static-23f1-fe0f.png +0 -0
  177. chatify/web/emoji/static-2600-fe0f.png +0 -0
  178. chatify/web/emoji/static-2615.png +0 -0
  179. chatify/web/emoji/static-267e-fe0f.png +0 -0
  180. chatify/web/emoji/static-26a1.png +0 -0
  181. chatify/web/emoji/static-2705.png +0 -0
  182. chatify/web/emoji/static-2728.png +0 -0
  183. chatify/web/emoji/static-274c.png +0 -0
  184. chatify/web/emoji/static-2764-fe0f.png +0 -0
  185. chatify/web/emoji/static-2795.png +0 -0
  186. chatify/web/emoji/static-27a1-fe0f.png +0 -0
  187. chatify/web/emoji/static-2b06-fe0f.png +0 -0
  188. chatify/web/emoji/static-2b50.png +0 -0
  189. chatify/web/emoji.js +1 -0
  190. chatify/web/error.html +10 -0
  191. chatify/web/featurecatalog.js +25 -0
  192. chatify/web/feedback.css +25 -0
  193. chatify/web/feedback.js +12 -0
  194. chatify/web/icons/chatify.svg +1 -0
  195. chatify/web/index.html +60 -0
  196. chatify/web/loading.css +5 -0
  197. chatify/web/loading.js +12 -0
  198. chatify/web/logos/claude.svg +1 -0
  199. chatify/web/logos/deepseek.svg +1 -0
  200. chatify/web/logos/gemini.svg +1 -0
  201. chatify/web/logos/groq.svg +1 -0
  202. chatify/web/logos/mimo.svg +1 -0
  203. chatify/web/logos/minimax.svg +1 -0
  204. chatify/web/logos/mistral.svg +1 -0
  205. chatify/web/logos/ollama.svg +1 -0
  206. chatify/web/logos/openai.svg +1 -0
  207. chatify/web/logos/openrouter.svg +1 -0
  208. chatify/web/motion.css +472 -0
  209. chatify/web/motion.js +125 -0
  210. chatify/web/navigation.css +30 -0
  211. chatify/web/nerdload.js +25 -0
  212. chatify/web/panels.js +148 -0
  213. chatify/web/pipeline.js +164 -0
  214. chatify/web/reading.js +39 -0
  215. chatify/web/refinements.css +115 -0
  216. chatify/web/settings.css +1336 -0
  217. chatify/web/settings.html +94 -0
  218. chatify/web/settings.js +968 -0
  219. chatify/web/settingsfind.js +23 -0
  220. chatify/web/settingsguide.js +22 -0
  221. chatify/web/settingssearch.js +12 -0
  222. chatify/web/settingssimple.js +33 -0
  223. chatify/web/settingstabs.css +338 -0
  224. chatify/web/sourcekeys.js +16 -0
  225. chatify/web/splitters.js +139 -0
  226. chatify/web/style.css +935 -0
  227. chatify/web/team/dennis.jpg +0 -0
  228. chatify/web/team/harsh.jpg +0 -0
  229. chatify/web/theme.js +168 -0
  230. chatify/web/tour.css +29 -0
  231. chatify/web/tour.js +68 -0
  232. chatify/web/workspace.js +25 -0
  233. chatify/websearch.py +113 -0
  234. chatify/workspace.py +50 -0
  235. chatify_app-0.2.0.dist-info/METADATA +61 -0
  236. chatify_app-0.2.0.dist-info/RECORD +240 -0
  237. chatify_app-0.2.0.dist-info/WHEEL +5 -0
  238. chatify_app-0.2.0.dist-info/entry_points.txt +5 -0
  239. chatify_app-0.2.0.dist-info/licenses/LICENSE +21 -0
  240. chatify_app-0.2.0.dist-info/top_level.txt +1 -0
chatify/mysqlstore.py ADDED
@@ -0,0 +1,88 @@
1
+ """Optional MySQL/MariaDB backend for Store. SQLite stays the default and the fallback.
2
+ Turned on by the CHATIFY_DB_URL environment variable, for example mysql://user:pass@host:3306/dbname.
3
+ The URL is read from the environment only, so a password never sits in a file in the repo.
4
+ Needs the PyMySQL package (pip install -r requirements-mysql.txt). If anything is missing or
5
+ unreachable, Store falls back to SQLite and says so."""
6
+ import os, re
7
+ from urllib.parse import urlparse, unquote
8
+
9
+ LONG = {"answer", "evidence", "ledger", "text", "payload", "summary", "standalone", "question"}
10
+
11
+
12
+ def parse(url):
13
+ u = urlparse(url)
14
+ if u.scheme not in ("mysql", "mariadb") or not u.hostname:
15
+ raise ValueError("CHATIFY_DB_URL must look like mysql://user:password@host:3306/dbname")
16
+ return dict(host=u.hostname, port=u.port or 3306, user=unquote(u.username or ""), password=unquote(u.password or ""),
17
+ database=(u.path or "/").lstrip("/") or "chatify")
18
+
19
+
20
+ def sql(s):
21
+ """Translate the SQLite statements the app uses into MySQL."""
22
+ s = s.replace("%", "%%").replace("?", "%s")
23
+ s = re.sub(r"(?i)^\s*INSERT OR REPLACE", "REPLACE", s)
24
+ s = re.sub(r"(?<![`\w])key(?![`\w])", "`key`", s)
25
+ return s
26
+
27
+
28
+ def ddl(stmt):
29
+ stmt = stmt.strip()
30
+ if not stmt:
31
+ return None
32
+ stmt = re.sub(r"(?i)^CREATE INDEX IF NOT EXISTS", "CREATE INDEX", stmt)
33
+ stmt = re.sub(r"(?i)\bREAL\b", "DOUBLE", stmt)
34
+ stmt = re.sub(r"(?i)\bBLOB\b", "LONGBLOB", stmt)
35
+ def col(m):
36
+ n = m.group(1)
37
+ return n + (" LONGTEXT" if n.lower() in LONG else " VARCHAR(190)")
38
+ stmt = re.sub(r"(?i)\b(\w+) TEXT\b", col, stmt)
39
+ stmt = re.sub(r"(?i)(LONGTEXT) DEFAULT ''", r"\1", stmt)
40
+ stmt = re.sub(r"(?<![`\w])key(?![`\w])", "`key`", stmt)
41
+ return stmt
42
+
43
+
44
+ class Conn:
45
+ """Looks enough like a sqlite3 connection for Store and Accounts."""
46
+ def __init__(self, cfg, driver=None):
47
+ if driver is None:
48
+ import pymysql
49
+ driver = pymysql
50
+ self.db = driver.connect(charset="utf8mb4", autocommit=False, connect_timeout=8,
51
+ cursorclass=driver.cursors.DictCursor, **cfg)
52
+
53
+ def execute(self, q, args=()):
54
+ cur = self.db.cursor()
55
+ cur.execute(sql(q), tuple(args))
56
+ return cur
57
+
58
+ def executescript(self, script):
59
+ for part in script.split(";"):
60
+ d = ddl(part)
61
+ if not d:
62
+ continue
63
+ try:
64
+ self.execute(d)
65
+ except Exception as e:
66
+ if "1061" in str(e) or "Duplicate key name" in str(e):
67
+ continue
68
+ raise
69
+ self.db.commit()
70
+
71
+ def commit(self):
72
+ self.db.commit()
73
+
74
+ def close(self):
75
+ try:
76
+ self.db.close()
77
+ except Exception:
78
+ pass
79
+
80
+ def __enter__(self):
81
+ return self
82
+
83
+ def __exit__(self, *a):
84
+ if a[0] is None:
85
+ self.db.commit()
86
+ else:
87
+ self.db.rollback()
88
+ return False
chatify/nerdload.py ADDED
@@ -0,0 +1,179 @@
1
+ """Review portable Nerds without importing code, then launch in a separate local workspace."""
2
+ import base64,hashlib,io,json,os,re,shutil,subprocess,sys,threading,zipfile
3
+ from pathlib import Path,PurePosixPath
4
+ import yaml
5
+ from . import schema
6
+ from .portable import _check_values,ExportError
7
+ MAX_UPLOAD=8*1024*1024
8
+ MAX_EXPANDED=32*1024*1024
9
+ _ALLOWED={'.yaml','.yml','.json','.md','.txt','.csv','.pdf','.py'}
10
+ class NerdLoader:
11
+ def __init__(self,folder):
12
+ self.root=Path(folder)/'nerds';self.pending={};self.children=[];self.workspaces={};self.lock=threading.Lock()
13
+ if self.root.is_dir():
14
+ for folder in self.root.glob('nerd-*'):
15
+ if folder.is_symlink() or not folder.is_dir():continue
16
+ try:
17
+ config=yaml.safe_load((folder/'app.yaml').read_text());title=str(config['app']['title'])[:120]
18
+ self.workspaces[folder.name]={'folder':folder,'process':None,'title':title,'url':''}
19
+ except (OSError,ValueError,TypeError,KeyError):pass
20
+ def review(self,name,encoded):
21
+ try:data=base64.b64decode(encoded,validate=True)
22
+ except Exception:raise ValueError('Invalid upload') from None
23
+ if sum(p.poll() is None for p in self.children)>=4:raise ValueError('Up to four Nerds can run here. Stop a running workspace before loading more.')
24
+ if len(data)>MAX_UPLOAD:raise ValueError('Bundle limit is 8 MB')
25
+ files={}
26
+ if name.lower().endswith('.zip'):
27
+ try:
28
+ with zipfile.ZipFile(io.BytesIO(data)) as z:
29
+ if len(z.infolist())>500:raise ValueError('Too many files')
30
+ # Validate paths and private names before ignoring benign metadata.
31
+ for info in z.infolist():
32
+ path=PurePosixPath(info.filename)
33
+ if path.is_absolute() or '..' in path.parts or '\\' in info.filename or re.match(r'^[A-Za-z]:',info.filename):raise ValueError('Unsafe path in bundle: '+info.filename)
34
+ if (info.external_attr>>16)&0o170000==0o120000:raise ValueError('Symlinks are not allowed: '+info.filename)
35
+ if any(re.match(r'^\.env(?:$|[._-])',part,re.I) or part.lower() in ('.ssh','.aws','.azure','.gnupg','.npmrc','.pypirc','.netrc') for part in path.parts):raise ValueError('Private file is not allowed: '+info.filename)
36
+ names={str(PurePosixPath(i.filename).name) for i in z.infolist() if not i.is_dir()}
37
+ if 'pyproject.toml' in names and any('/chatify/__init__.py' in '/'+i.filename for i in z.infolist()):raise ValueError('This is the Chatify source ZIP, not a Nerd bundle. Install it to run Chatify. To share a Nerd, use Download app ZIP in Configuration.')
38
+ total=0
39
+ for info in z.infolist():
40
+ path=PurePosixPath(info.filename)
41
+ if path.is_absolute() or '..' in path.parts or '\\' in info.filename or re.match(r'^[A-Za-z]:',info.filename):raise ValueError('Unsafe path in bundle')
42
+ if (info.external_attr>>16)&0o170000==0o120000:raise ValueError('Symlinks are not allowed')
43
+ if info.is_dir():continue
44
+ benign={'.gitignore','.gitattributes','.editorconfig','.DS_Store','.github','.git','__MACOSX'}
45
+ hidden=[x for x in path.parts if x.startswith('.') or x=='__MACOSX']
46
+ if hidden:
47
+ if all(x in benign or x.startswith('._') for x in hidden):continue
48
+ raise ValueError('Unsupported hidden file: '+info.filename)
49
+ if re.search(r'(secret|credential|password|token|chatify\.db|accounts|sessions)',info.filename,re.I):raise ValueError('Private file is not allowed: '+info.filename)
50
+ if len(info.filename)>240:raise ValueError('Path too long')
51
+ if path.suffix.lower() not in _ALLOWED:raise ValueError('Unsupported file: '+info.filename)
52
+ total+=info.file_size
53
+ if total>MAX_EXPANDED:raise ValueError('Expanded bundle is too large')
54
+ if info.filename in files:raise ValueError('Duplicate path')
55
+ files[info.filename]=z.read(info)
56
+ except zipfile.BadZipFile:raise ValueError('Invalid ZIP') from None
57
+ elif name.lower().endswith(('.yaml','.yml','.json')):files['app.yaml']=data
58
+ else:raise ValueError('Choose a Nerd ZIP or YAML/JSON app file')
59
+ apps=[n for n in files if PurePosixPath(n).name in ('app.yaml','app.yml','app.json')]
60
+ if len(apps)!=1:raise ValueError('Bundle must contain exactly one app.yaml (or app.json)')
61
+ prefix=str(PurePosixPath(apps[0]).parent)
62
+ if prefix!='.':
63
+ if any(not n.startswith(prefix+'/') for n in files):raise ValueError('All files must be inside the app folder')
64
+ files={n[len(prefix)+1:]:v for n,v in files.items()}
65
+ appname=next(n for n in files if PurePosixPath(n).name in ('app.yaml','app.yml','app.json'))
66
+ try:raw=yaml.safe_load(files[appname].decode('utf-8'))
67
+ except Exception:raise ValueError('Invalid app YAML/JSON') from None
68
+ if isinstance(raw,dict) and 'articles' in raw and 'app' not in raw and 'provider' not in raw and 'sources' not in raw:
69
+ raise ValueError('This looks like an API response, not a Nerd app. In Code helpers, open Search connector, then Match a real response and paste this JSON there. It will not be imported or run.')
70
+ from .keybundle import extract
71
+ raw,imported_keys=extract(raw)
72
+ try:cfg=schema.validate(raw);_check_values(cfg)
73
+ except (schema.ConfigError,ExportError) as e:raise ValueError(str(e)) from None
74
+ files[appname]=yaml.safe_dump(cfg,sort_keys=False).encode()
75
+ for name,content in files.items():
76
+ if name.endswith('.json') and name!=appname:
77
+ try:_check_values(json.loads(content))
78
+ except (ValueError,ExportError):raise ValueError('Invalid or private JSON file: '+name) from None
79
+ if cfg['auth']['mode']!='none':raise ValueError('Local imported Nerds must use auth.mode: none. Set up hosted authentication separately.')
80
+ for name in ('data/theme.json','data/prompts.json'):
81
+ if name in files:
82
+ from . import theme,prompts
83
+ d=json.loads(files[name])
84
+ if name.endswith('theme.json'):files[name]=json.dumps(theme.clean(d)).encode()
85
+ elif not isinstance(d,dict) or set(d)-{'text','on'} or not isinstance(d.get('text',{}),dict) or not isinstance(d.get('on',{}),dict) or any(k not in prompts.STAGES or not isinstance(v,str) or len(v)>prompts.MAX_LEN for k,v in d.get('text',{}).items()) or any(k not in prompts.STAGES or type(v)!=bool for k,v in d.get('on',{}).items()):raise ValueError('Invalid prompt settings')
86
+ empty_sources=[]
87
+ for src in cfg['sources']:
88
+ for field in ('path','module'):
89
+ v=src.get(field)
90
+ if not v:continue
91
+ p=PurePosixPath(v)
92
+ if p.is_absolute() or '..' in p.parts or '\\' in v:raise ValueError('Sources must stay inside the bundle')
93
+ if src['type']=='local_files':
94
+ path=src.get('path','docs').rstrip('/')
95
+ if not any(n.startswith(path+'/') for n in files):empty_sources.append((src['label'],path))
96
+ if src['type']=='python':
97
+ mod=src.get('entry','').partition(':')[0]
98
+ if not re.fullmatch(r'[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*',mod):raise ValueError('Invalid Python module entry')
99
+ if mod.replace('.','/')+'.py' not in files:raise ValueError('Python connector module is missing from bundle')
100
+ for src in cfg['sources']:
101
+ if src['type']=='python':src['execution']='bounded'
102
+ cfg['storage']={'path':'data/chatify.db'};cfg['server']['host']='127.0.0.1';cfg['actions']={'allow':[],'writes':False}
103
+ files.pop(appname);files['app.yaml']=yaml.safe_dump(cfg,sort_keys=False).encode()
104
+ code=[n for n in files if n.endswith('.py')]
105
+ if sum(len(files[n]) for n in code)>200000:raise ValueError('Connector code is too large to review here')
106
+ envs=sorted({src.get('api_key_env') for src in cfg['sources'] if src.get('api_key_env')}|({cfg['provider']['api_key_env']} if cfg['provider'].get('api_key_env') else set()))
107
+ from .readiness import check
108
+ readiness=check(cfg,files,raw)
109
+ for label,path in empty_sources:readiness['rows'].append({'kind':'setup','item':label,'message':'No documents included for '+path+'. An empty source folder will be created. Add documents in the new workspace before asking source-backed questions.'})
110
+ if not readiness['can_load']:raise ValueError('Python connector does not compile. Fix it before loading.')
111
+ envs=readiness['required_env']
112
+ token=hashlib.sha256(data).hexdigest()
113
+ with self.lock:
114
+ if len(self.pending)>=4:self.pending.pop(next(iter(self.pending)))
115
+ self.pending[token]=(files,cfg,code,imported_keys)
116
+ from . import theme
117
+ look=theme.clean(json.loads(files['data/theme.json'])) if 'data/theme.json' in files else theme.clean(cfg.get('appearance',{}))
118
+ preview={'title':look.get('txt_title') or cfg['app']['title'],'tagline':look.get('txt_tagline') or cfg['app']['tagline'],'mode':look.get('mode','light'),'colors':look.get('dark' if look.get('mode')=='dark' else 'light',{}),'examples':cfg['app'].get('examples',[])[:3],'files':sorted(files),'static':True}
119
+ from . import bundle_review
120
+ return {'auto_review':bundle_review.review(cfg,files),'preview':preview,'readiness':readiness,'token':token,'title':cfg['app']['title'],'tagline':cfg['app']['tagline'],'provider':cfg['provider']['type'],'model':cfg['provider']['model'],'sources':[{'id':s['id'],'label':s.get('label',s['id']),'type':s['type']} for s in cfg['sources']],'required_env':envs,'imported_key_count':len(imported_keys),'code':[{'path':n,'text':files[n].decode('utf-8')} for n in code],'warnings':['Python uses a bounded process, NOT a sandbox. Host files/network remain accessible; code review required.','Separate local workspace. Current app is not overwritten.',str(len(imported_keys))+' API key entries will be placed in this workspace private key storage. Values are not shown. Accounts and chat history are not imported.','Imported local apps disable write actions. Review remote sources before asking a question.']}
121
+ def load(self,token,allow_code=False):
122
+ with self.lock:item=self.pending.get(token)
123
+ if not item:raise ValueError('Review the bundle first')
124
+ files,cfg,code,imported_keys=item
125
+ if sum(p.poll() is None for p in self.children)>=4:raise ValueError('Maximum four running Nerds. Stop a running workspace before loading more.')
126
+ if code and not allow_code:raise ValueError('Review Python code and explicitly allow it before loading')
127
+ from .launcher import pick_port
128
+ import tempfile
129
+ self.root.mkdir(parents=True,exist_ok=True)
130
+ folder=Path(tempfile.mkdtemp(prefix='nerd-',dir=str(self.root)))
131
+ for name,data in files.items():
132
+ p=folder/name;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(data)
133
+ for src in cfg['sources']:
134
+ if src['type']=='local_files':(folder/src.get('path','docs')).mkdir(parents=True,exist_ok=True)
135
+ port=pick_port(8090)
136
+ cfg['server']['port']=port;(folder/'app.yaml').write_text(yaml.safe_dump(cfg,sort_keys=False))
137
+ if imported_keys:
138
+ from .secrets import SecretStore
139
+ key_store=SecretStore(str(folder/'data'))
140
+ for name,value in imported_keys.items():key_store.set(name,value)
141
+ log=open(folder/'run.log','wb')
142
+ declared_env={cfg['provider'].get('api_key_env','')}|{src.get('api_key_env','') for src in cfg['sources']}
143
+ child=subprocess.Popen([sys.executable,'-m','chatify','run',str(folder/'app.yaml'),'--no-browser'],stdout=log,stderr=subprocess.STDOUT,cwd=str(Path(__file__).resolve().parents[1]),env={k:v for k,v in os.environ.items() if not re.search(r'(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)',k,re.I) or k in declared_env})
144
+ log.close();self.children.append(child);self.workspaces[folder.name]={'folder':folder,'process':child,'title':cfg['app']['title'],'url':'http://127.0.0.1:'+str(port)}
145
+ import time,urllib.request
146
+ url='http://127.0.0.1:'+str(port)
147
+ for _ in range(40):
148
+ if child.poll() is not None:raise ValueError('Nerd could not start. Check dependencies and connector setup in '+str(folder/'run.log'))
149
+ try:
150
+ with urllib.request.urlopen(url+'/api/health',timeout=.5):return {'url':url,'title':cfg['app']['title'],'workspace':str(folder)}
151
+ except OSError:time.sleep(.1)
152
+ child.terminate();raise ValueError('Nerd did not become ready; current app is unchanged')
153
+ def list(self):
154
+ with self.lock:
155
+ return [{'id':key,'title':v['title'],'url':v['url'],'running':v['process'] is not None and v['process'].poll() is None} for key,v in self.workspaces.items()]
156
+ def stop(self,key):
157
+ with self.lock:item=self.workspaces.get(key)
158
+ if not item:raise ValueError('Unknown imported workspace')
159
+ proc=item['process']
160
+ if proc and proc.poll() is None:
161
+ proc.terminate()
162
+ try:proc.wait(timeout=3)
163
+ except subprocess.TimeoutExpired:proc.kill();proc.wait()
164
+ return {'stopped':True}
165
+ def remove(self,key,confirmed):
166
+ if confirmed is not True:raise ValueError('Confirm removal of this imported workspace')
167
+ with self.lock:item=self.workspaces.get(key)
168
+ if not item:raise ValueError('Unknown imported workspace')
169
+ self.stop(key);folder=item['folder']
170
+ if folder.is_symlink() or folder.parent.resolve()!=self.root.resolve():raise ValueError('Workspace path changed; removal stopped')
171
+ shutil.rmtree(folder)
172
+ with self.lock:self.workspaces.pop(key,None)
173
+ return {'removed':True}
174
+ def close(self):
175
+ for child in self.children:
176
+ if child.poll() is None:
177
+ child.terminate()
178
+ try:child.wait(timeout=3)
179
+ except subprocess.TimeoutExpired:child.kill();child.wait()
@@ -0,0 +1,138 @@
1
+ """Opt-in local Ollama installer. Fixed official sources, no user shell commands."""
2
+ import hashlib, os, platform, secrets, shutil, subprocess, tempfile, threading, time, urllib.request
3
+ from pathlib import Path
4
+ from urllib.parse import urlparse
5
+ from . import hardware
6
+ SOURCES = {'Windows': 'https://ollama.com/download/OllamaSetup.exe', 'Darwin': 'https://ollama.com/download/Ollama.dmg', 'Linux': 'https://ollama.com/install.sh'}
7
+ PAGES = {'Windows': 'https://ollama.com/download/windows', 'Darwin': 'https://ollama.com/download/mac', 'Linux': 'https://ollama.com/download/linux'}
8
+ BASE = 'http://127.0.0.1:11434'
9
+ LIMIT = 2 * 1024 ** 3
10
+
11
+ def detect():
12
+ system = platform.system()
13
+ binary = shutil.which('ollama')
14
+ if not binary and system == 'Darwin':
15
+ p = Path('/Applications/Ollama.app/Contents/Resources/ollama')
16
+ if p.is_file(): binary = str(p)
17
+ if not binary and system == 'Windows':
18
+ p = Path(os.environ.get('LOCALAPPDATA', '')) / 'Programs/Ollama/ollama.exe'
19
+ if p.is_file(): binary = str(p)
20
+ running = hardware.installed(BASE) is not None
21
+ return {'system': system, 'arch': platform.machine(), 'state': 'running' if running else 'stopped' if binary else 'not_detected',
22
+ 'binary': bool(binary), 'supported': system in SOURCES, 'source': SOURCES.get(system, ''), 'page': PAGES.get(system, ''),
23
+ 'disk_free_gb': round(shutil.disk_usage(tempfile.gettempdir()).free / 1024 ** 3, 1),
24
+ 'host_note': 'The computer running Chatify, not the browser or phone.', '_binary': binary}
25
+
26
+ class OfficialRedirect(urllib.request.HTTPRedirectHandler):
27
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
28
+ if urlparse(newurl).scheme != 'https' or urlparse(newurl).hostname not in ('ollama.com', 'github.com', 'objects.githubusercontent.com', 'release-assets.githubusercontent.com'):
29
+ raise ValueError('Installer redirected outside the official download hosts')
30
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
31
+
32
+ def download(url, folder, cancelled=None, progress=None):
33
+ if url not in SOURCES.values(): raise ValueError('Unknown installer source')
34
+ target = Path(folder) / url.rsplit('/', 1)[1]
35
+ opener = urllib.request.build_opener(OfficialRedirect)
36
+ with opener.open(url, timeout=60) as response, target.open('wb') as out:
37
+ total = int(response.headers.get('Content-Length') or 0)
38
+ if total > LIMIT: raise ValueError('Installer exceeds the 2 GB download limit')
39
+ used = 0
40
+ while True:
41
+ if cancelled and cancelled.is_set(): raise ValueError('Download cancelled')
42
+ chunk = response.read(1024 * 1024)
43
+ if not chunk: break
44
+ used += len(chunk)
45
+ if used > LIMIT: raise ValueError('Installer exceeds the 2 GB download limit')
46
+ out.write(chunk)
47
+ if progress:progress(used,total)
48
+ if not used: raise ValueError('Empty installer download')
49
+ return target
50
+
51
+ class OllamaSetup:
52
+ def __init__(self):
53
+ self.lock = threading.Lock(); self.pending = {}; self.jobs = {}; self.processes = []; self.cancel_events = {}; self.job_processes = {}
54
+ def status(self):
55
+ data = detect(); data.pop('_binary', None)
56
+ return data
57
+ def prepare(self, action):
58
+ if action not in ('install', 'start'): raise ValueError('Choose install or start')
59
+ data = detect(); data.pop('_binary', None)
60
+ if not data['supported']: raise ValueError('Use the official Ollama installer on this platform')
61
+ if action == 'install' and data['state'] != 'not_detected': raise ValueError('Ollama is already detected. Start it or recheck instead.')
62
+ if action == 'start' and data['state'] != 'stopped': raise ValueError('Start is available only for a detected, stopped Ollama')
63
+ if action == 'install' and data['disk_free_gb'] < 5: raise ValueError('At least 5 GB free disk space is required before installation. Models need more.')
64
+ ticket = secrets.token_urlsafe(24)
65
+ with self.lock:
66
+ self.pending = {ticket: (action, time.monotonic(), data['system'])}
67
+ data.update(ticket=ticket, action=action, download_size='Not known until download; limit 2 GB',
68
+ changes='Install Ollama on this host. OS installer may require local interaction or permission. No models or cloud accounts added.' if action == 'install' else 'Start the detected local Ollama service.',
69
+ instructions={'Darwin': 'In the disk image, drag Ollama to Applications, then open it. Approve the CLI link only in the local OS prompt.',
70
+ 'Windows': 'Complete the official installer on this computer. Ollama should run in the background.',
71
+ 'Linux': 'The official script may require sudo. Chatify never asks for a password. If permission is needed, follow the official page in a local terminal.'}[data['system']])
72
+ return data
73
+ def execute(self, ticket, confirmed):
74
+ if confirmed is not True: raise ValueError('Review and confirm this installation first')
75
+ with self.lock:
76
+ item = self.pending.pop(ticket, None)
77
+ if not item or time.monotonic() - item[1] > 600: raise ValueError('Review the installation again; approval expired')
78
+ if any(x['state'] in ('downloading', 'starting', 'waiting') for x in self.jobs.values()): raise ValueError('A setup is already in progress')
79
+ action, _, system = item
80
+ if system != platform.system(): raise ValueError('Host changed; review again')
81
+ current = detect()
82
+ if action == 'install' and current['state'] != 'not_detected': raise ValueError('Ollama is now detected; recheck instead of installing again')
83
+ if action == 'start' and current['state'] != 'stopped': raise ValueError('Service state changed; recheck')
84
+ jid = secrets.token_urlsafe(18)
85
+ self.cancel_events[jid]=threading.Event()
86
+ self.jobs = {jid: {'id': jid, 'state': 'starting' if action == 'start' else 'downloading', 'message': 'Starting Ollama' if action == 'start' else 'Downloading the official installer', 'action': action}}
87
+ threading.Thread(target=self._work, args=(jid, action, system), daemon=True).start()
88
+ return {'id': jid}
89
+ def job(self, jid):
90
+ with self.lock: result = dict(self.jobs.get(jid, {}))
91
+ if not result: raise ValueError('Unknown setup')
92
+ return result
93
+ def _update(self, jid, **values):
94
+ with self.lock:
95
+ if self.jobs[jid]['state']!='cancelled':self.jobs[jid].update(values)
96
+ def cancel(self, jid):
97
+ with self.lock:
98
+ job=self.jobs.get(jid)
99
+ if not job:raise ValueError('Unknown setup')
100
+ if job['state'] in ('ready','failed','needs_manual','cancelled'):return dict(job)
101
+ self.cancel_events[jid].set()
102
+ proc=self.job_processes.get(jid)
103
+ if proc and proc.poll() is None:
104
+ try:proc.terminate()
105
+ except OSError:pass
106
+ job.update(state='cancelled',message='Chatify stopped its setup work. An OS wizard or child installer may still be open; close it on the host. Installed changes are not rolled back. Retry requires a new review; installer downloads restart, not resume.')
107
+ return dict(job)
108
+ def _work(self, jid, action, system):
109
+ try:
110
+ if action == 'start':
111
+ binary = detect().get('_binary')
112
+ if not binary: raise ValueError('Ollama executable no longer detected; recheck')
113
+ cmd = [binary, 'serve']
114
+ else:
115
+ # Keep mounted disk images available until the OS wizard is finished.
116
+ folder = tempfile.mkdtemp(prefix='chatify-ollama-')
117
+ target = download(SOURCES[system], folder, self.cancel_events.get(jid), lambda used,total:self._update(jid,downloaded_bytes=used,total_bytes=total,progress_pct=round(100*used/total) if total else None))
118
+ digestor = hashlib.sha256()
119
+ with target.open('rb') as downloaded:
120
+ for chunk in iter(lambda: downloaded.read(1024 * 1024), b''): digestor.update(chunk)
121
+ digest = digestor.hexdigest()
122
+ self._update(jid, state='starting', message='Opening the official installer on this computer', sha256=digest)
123
+ cmd = [str(target)] if system == 'Windows' else ['open', str(target)] if system == 'Darwin' else ['sh', str(target)]
124
+ with self.lock:
125
+ if self.cancel_events.get(jid) and self.cancel_events[jid].is_set(): return
126
+ proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
127
+ self.processes.append(proc);self.job_processes[jid]=proc
128
+ self._update(jid, state='waiting', message='Complete any OS installer or permission prompt on the host, then recheck. No password is collected here.')
129
+ for _ in range(300):
130
+ if self.cancel_events.get(jid) and self.cancel_events[jid].is_set():return
131
+ if hardware.installed(BASE) is not None:
132
+ self._update(jid, state='ready', message='Local Ollama service verified. Choose a model separately.'); return
133
+ if system == 'Linux' and proc.poll() not in (None, 0):
134
+ self._update(jid, state='needs_manual', message='Installer could not complete non-interactively. Use the official install page in a local terminal; then recheck.', page=PAGES[system]); return
135
+ time.sleep(2)
136
+ self._update(jid, state='needs_manual', message='Local service is not reachable yet. Complete the OS setup or use the official instructions, then recheck.', page=PAGES[system])
137
+ except Exception:
138
+ self._update(jid, state='failed', message='Setup could not complete. No success claimed. Use the official install page, then recheck.', page=PAGES.get(system, 'https://ollama.com'))
chatify/pdfread.py ADDED
@@ -0,0 +1,97 @@
1
+ """Pull plain text out of a PDF. Uses pypdf when installed; otherwise a small built-in reader
2
+ that handles ordinary text PDFs (Flate-compressed streams with Tj/TJ text operators).
3
+ Scanned PDFs (images only) have no text to extract and raise a clear error."""
4
+ import re, zlib
5
+
6
+ MAX_BYTES = 8_000_000
7
+
8
+
9
+ def _unescape(s):
10
+ s = re.sub(rb"\\([nrtbf()\\])", lambda m: {b"n": b"\n", b"r": b"\r", b"t": b"\t", b"b": b"", b"f": b""}.get(m.group(1), m.group(1)), s)
11
+ return re.sub(rb"\\([0-7]{1,3})", lambda m: bytes([int(m.group(1), 8) & 255]), s)
12
+
13
+
14
+ def _builtin(data):
15
+ out = []
16
+ for m in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", data, re.S):
17
+ raw = m.group(1)
18
+ try:
19
+ raw = zlib.decompress(raw)
20
+ except Exception:
21
+ pass
22
+ if b"BT" not in raw:
23
+ continue
24
+ for blk in re.findall(rb"BT(.*?)ET", raw, re.S):
25
+ for arr in re.finditer(rb"\[(.*?)\]\s*TJ|\((.*?)(?<!\\)\)\s*(?:Tj|'|\")", blk, re.S):
26
+ line = []
27
+ if arr.group(1) is not None:
28
+ parts = re.findall(rb"\(((?:\\.|[^\\)])*)\)|(-?\d+\.?\d*)", arr.group(1))
29
+ for t, k in parts:
30
+ if t or not k:
31
+ line.append(_unescape(t).decode("latin-1"))
32
+ elif float(k) < -200:
33
+ line.append(" ")
34
+ else:
35
+ line.append(_unescape(arr.group(2)).decode("latin-1"))
36
+ if line:
37
+ out.append("".join(line))
38
+ return "\n".join(out)
39
+
40
+
41
+ def extract(data):
42
+ if not data.startswith(b"%PDF"):
43
+ raise ValueError("That file is not a PDF")
44
+ if len(data) > MAX_BYTES:
45
+ raise ValueError("PDF is over 8 MB")
46
+ text = ""
47
+ try:
48
+ import io
49
+ from pypdf import PdfReader
50
+ text = "\n\n".join((p.extract_text() or "") for p in PdfReader(io.BytesIO(data)).pages)
51
+ except ImportError:
52
+ text = _builtin(data)
53
+ except Exception:
54
+ text = _builtin(data)
55
+ text = re.sub(r"[ \t]+", " ", text).strip()
56
+ if len(text) < 20:
57
+ raise ValueError("No readable text in this PDF (it may be a scan)")
58
+ return text[:150_000]
59
+
60
+
61
+ def pages(data, ocr=False, max_pages=100):
62
+ """Page-preserving extraction; OCR is explicit and local, never a web upload."""
63
+ if not data.startswith(b"%PDF") or len(data) > MAX_BYTES:
64
+ raise ValueError("PDF must be valid and at most 8 MB")
65
+ try:
66
+ import io
67
+ from pypdf import PdfReader
68
+ except ImportError:
69
+ raise ValueError("Page anchors need pypdf. Install chatify[documents].") from None
70
+ reader = PdfReader(io.BytesIO(data))
71
+ if len(reader.pages) > max_pages:
72
+ raise ValueError("PDF exceeds the 100-page local extraction limit")
73
+ out = []
74
+ for number, page in enumerate(reader.pages, 1):
75
+ text = (page.extract_text() or "").strip()
76
+ if len(text) < 20 and ocr:
77
+ text = _ocr_page(data, number)
78
+ if text: out.append({"page": number, "text": text[:150000], "ocr": len((page.extract_text() or "").strip()) < 20 and ocr})
79
+ if not out: raise ValueError("No readable text. Set ocr: true and install local OCR tools for scans.")
80
+ return out
81
+
82
+
83
+ def _ocr_page(data, number):
84
+ import shutil, subprocess, tempfile
85
+ from pathlib import Path
86
+ if not shutil.which("pdftoppm") or not shutil.which("tesseract"):
87
+ raise ValueError("OCR needs pdftoppm (Poppler) and Tesseract installed on this computer")
88
+ with tempfile.TemporaryDirectory(prefix="chatify-ocr-") as folder:
89
+ root = Path(folder); source = root / "document.pdf"; source.write_bytes(data)
90
+ try:
91
+ subprocess.run(["pdftoppm", "-f", str(number), "-l", str(number), "-scale-to", "2000", "-singlefile", "-png", str(source), str(root / "page")],
92
+ check=True, timeout=30, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
93
+ result = subprocess.run(["tesseract", str(root / "page.png"), "stdout"], check=True, timeout=30,
94
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
95
+ except (subprocess.SubprocessError, OSError):
96
+ raise ValueError("Local OCR failed or timed out; no text from that page was indexed") from None
97
+ return result.stdout.decode("utf-8", errors="replace").strip()
chatify/permissions.py ADDED
@@ -0,0 +1,21 @@
1
+ """Explicit source/document readers. Missing policy is public, empty readers deny all."""
2
+ import copy
3
+
4
+ def restricted(cfg):return any('read_users' in s or s.get('document_users') for s in cfg['sources'])
5
+ def source(cfg,sid):return next((s for s in cfg['sources'] if s['id']==sid),None)
6
+ def allowed(cfg,owner,sid,document=None):
7
+ s=source(cfg,sid)
8
+ if s is None:return not restricted(cfg) or sid=='uploads' # own uploads are retrieved through the owner store only
9
+ if 'read_users' in s and owner not in s['read_users']:return False
10
+ rules=s.get('document_users',{})
11
+ return document not in rules or owner in rules[document]
12
+ def documents(cfg,owner,sid,conn):
13
+ if not allowed(cfg,owner,sid):return []
14
+ s=source(cfg,sid)
15
+ if not s or not s.get('document_users'):return None
16
+ return sorted({d.get('document') for d in conn.docs if d.get('document') and allowed(cfg,owner,sid,d['document'])})
17
+ def view(cfg,owner,row):
18
+ row=copy.deepcopy(row)
19
+ if any(not allowed(cfg,owner,e.get('source'),e.get('document')) for e in row.get('evidence',[])):
20
+ row.update(answer='This saved answer is no longer available under your document permissions.',evidence=[],ledger=[],standalone='',followups=[],scope=None)
21
+ return row