@purpleraven/hits 0.2.0
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/AGENTS.md +298 -0
- package/LICENSE +190 -0
- package/README.md +336 -0
- package/bin/hits.js +56 -0
- package/config/schema.json +94 -0
- package/config/settings.yaml +102 -0
- package/data/dev_handover.yaml +143 -0
- package/hits_core/__init__.py +9 -0
- package/hits_core/ai/__init__.py +11 -0
- package/hits_core/ai/compressor.py +86 -0
- package/hits_core/ai/llm_client.py +65 -0
- package/hits_core/ai/slm_filter.py +126 -0
- package/hits_core/api/__init__.py +3 -0
- package/hits_core/api/routes/__init__.py +8 -0
- package/hits_core/api/routes/auth.py +211 -0
- package/hits_core/api/routes/handover.py +117 -0
- package/hits_core/api/routes/health.py +8 -0
- package/hits_core/api/routes/knowledge.py +177 -0
- package/hits_core/api/routes/node.py +121 -0
- package/hits_core/api/routes/work_log.py +174 -0
- package/hits_core/api/server.py +181 -0
- package/hits_core/auth/__init__.py +21 -0
- package/hits_core/auth/dependencies.py +61 -0
- package/hits_core/auth/manager.py +368 -0
- package/hits_core/auth/middleware.py +69 -0
- package/hits_core/collector/__init__.py +18 -0
- package/hits_core/collector/ai_session_collector.py +118 -0
- package/hits_core/collector/base.py +73 -0
- package/hits_core/collector/daemon.py +94 -0
- package/hits_core/collector/git_collector.py +177 -0
- package/hits_core/collector/hits_action_collector.py +110 -0
- package/hits_core/collector/shell_collector.py +178 -0
- package/hits_core/main.py +36 -0
- package/hits_core/mcp/__init__.py +20 -0
- package/hits_core/mcp/server.py +429 -0
- package/hits_core/models/__init__.py +18 -0
- package/hits_core/models/node.py +56 -0
- package/hits_core/models/tree.py +68 -0
- package/hits_core/models/work_log.py +64 -0
- package/hits_core/models/workflow.py +92 -0
- package/hits_core/platform/__init__.py +5 -0
- package/hits_core/platform/actions.py +225 -0
- package/hits_core/service/__init__.py +6 -0
- package/hits_core/service/handover_service.py +382 -0
- package/hits_core/service/knowledge_service.py +172 -0
- package/hits_core/service/tree_service.py +105 -0
- package/hits_core/storage/__init__.py +11 -0
- package/hits_core/storage/base.py +84 -0
- package/hits_core/storage/file_store.py +314 -0
- package/hits_core/storage/redis_store.py +123 -0
- package/hits_web/dist/assets/index-Bgx7F6m6.css +1 -0
- package/hits_web/dist/assets/index-D1B5E67G.js +3 -0
- package/hits_web/dist/index.html +16 -0
- package/package.json +60 -0
- package/requirements-core.txt +7 -0
- package/requirements.txt +1 -0
- package/run.sh +271 -0
- package/server.js +234 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Storage abstraction layer."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from ..models.tree import KnowledgeTree
|
|
7
|
+
from ..models.workflow import Workflow
|
|
8
|
+
from ..models.work_log import WorkLog
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseStorage(ABC):
|
|
12
|
+
@abstractmethod
|
|
13
|
+
async def save_tree(self, tree: KnowledgeTree) -> bool:
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def load_tree(self, tree_id: str) -> Optional[KnowledgeTree]:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def delete_tree(self, tree_id: str) -> bool:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
async def list_trees(self) -> list[str]:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
async def save_workflow(self, workflow: Workflow) -> bool:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
async def load_workflow(self, workflow_id: str) -> Optional[Workflow]:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
async def delete_workflow(self, workflow_id: str) -> bool:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
async def list_workflows(self) -> list[str]:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
async def save_work_log(self, log: WorkLog) -> bool:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
async def load_work_log(self, log_id: str) -> Optional[WorkLog]:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
async def delete_work_log(self, log_id: str) -> bool:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
async def list_work_logs(
|
|
58
|
+
self,
|
|
59
|
+
performed_by: Optional[str] = None,
|
|
60
|
+
source: Optional[str] = None,
|
|
61
|
+
since: Optional[datetime] = None,
|
|
62
|
+
project_path: Optional[str] = None,
|
|
63
|
+
limit: int = 100,
|
|
64
|
+
) -> list[WorkLog]:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
async def search_work_logs(
|
|
69
|
+
self,
|
|
70
|
+
query: str,
|
|
71
|
+
project_path: Optional[str] = None,
|
|
72
|
+
limit: int = 50,
|
|
73
|
+
) -> list[WorkLog]:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
async def list_project_paths(self) -> list[str]:
|
|
78
|
+
"""Return all unique project paths that have work logs."""
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
async def get_project_summary(self, project_path: str) -> dict:
|
|
83
|
+
"""Get aggregated statistics for a specific project."""
|
|
84
|
+
pass
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""File-based storage backend for fallback and local development."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from .base import BaseStorage
|
|
10
|
+
from ..models.tree import KnowledgeTree
|
|
11
|
+
from ..models.workflow import Workflow
|
|
12
|
+
from ..models.work_log import WorkLog
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FileStorage(BaseStorage):
|
|
16
|
+
TREE_DIR = "trees"
|
|
17
|
+
WORKFLOW_DIR = "workflows"
|
|
18
|
+
WORK_LOG_DIR = "work_logs"
|
|
19
|
+
INDEX_FILE = "index.json"
|
|
20
|
+
|
|
21
|
+
# Centralized data home - all AI tools write to the same location
|
|
22
|
+
DEFAULT_DATA_HOME = Path.home() / ".hits" / "data"
|
|
23
|
+
|
|
24
|
+
def __init__(self, base_path: Optional[str] = None):
|
|
25
|
+
if base_path:
|
|
26
|
+
self.base_path = Path(base_path)
|
|
27
|
+
else:
|
|
28
|
+
# Priority: env var > ~/.hits/data/ (centralized)
|
|
29
|
+
env_path = os.environ.get("HITS_DATA_PATH")
|
|
30
|
+
if env_path:
|
|
31
|
+
self.base_path = Path(env_path)
|
|
32
|
+
else:
|
|
33
|
+
self.base_path = self.DEFAULT_DATA_HOME
|
|
34
|
+
|
|
35
|
+
self.tree_dir = self.base_path / self.TREE_DIR
|
|
36
|
+
self.workflow_dir = self.base_path / self.WORKFLOW_DIR
|
|
37
|
+
self.work_log_dir = self.base_path / self.WORK_LOG_DIR
|
|
38
|
+
|
|
39
|
+
self.tree_dir.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
self.workflow_dir.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
self.work_log_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
|
|
43
|
+
def _tree_path(self, tree_id: str) -> Path:
|
|
44
|
+
return self.tree_dir / f"{tree_id}.json"
|
|
45
|
+
|
|
46
|
+
def _workflow_path(self, workflow_id: str) -> Path:
|
|
47
|
+
return self.workflow_dir / f"{workflow_id}.json"
|
|
48
|
+
|
|
49
|
+
def _work_log_path(self, log_id: str) -> Path:
|
|
50
|
+
return self.work_log_dir / f"{log_id}.json"
|
|
51
|
+
|
|
52
|
+
def _index_path(self, dir_path: Path) -> Path:
|
|
53
|
+
return dir_path / self.INDEX_FILE
|
|
54
|
+
|
|
55
|
+
def _read_index(self, dir_path: Path) -> list[str]:
|
|
56
|
+
index_file = self._index_path(dir_path)
|
|
57
|
+
if index_file.exists():
|
|
58
|
+
with open(index_file, "r", encoding="utf-8") as f:
|
|
59
|
+
return json.load(f)
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
def _write_index(self, dir_path: Path, items: list[str]) -> None:
|
|
63
|
+
index_file = self._index_path(dir_path)
|
|
64
|
+
with open(index_file, "w", encoding="utf-8") as f:
|
|
65
|
+
json.dump(items, f)
|
|
66
|
+
|
|
67
|
+
async def save_tree(self, tree: KnowledgeTree) -> bool:
|
|
68
|
+
try:
|
|
69
|
+
path = self._tree_path(tree.id)
|
|
70
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
71
|
+
f.write(tree.model_dump_json(indent=2))
|
|
72
|
+
|
|
73
|
+
index = self._read_index(self.tree_dir)
|
|
74
|
+
if tree.id not in index:
|
|
75
|
+
index.append(tree.id)
|
|
76
|
+
self._write_index(self.tree_dir, index)
|
|
77
|
+
|
|
78
|
+
return True
|
|
79
|
+
except Exception:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
async def load_tree(self, tree_id: str) -> Optional[KnowledgeTree]:
|
|
83
|
+
try:
|
|
84
|
+
path = self._tree_path(tree_id)
|
|
85
|
+
if not path.exists():
|
|
86
|
+
return None
|
|
87
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
88
|
+
return KnowledgeTree.model_validate_json(f.read())
|
|
89
|
+
except Exception:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
async def delete_tree(self, tree_id: str) -> bool:
|
|
93
|
+
try:
|
|
94
|
+
path = self._tree_path(tree_id)
|
|
95
|
+
if path.exists():
|
|
96
|
+
path.unlink()
|
|
97
|
+
|
|
98
|
+
index = self._read_index(self.tree_dir)
|
|
99
|
+
if tree_id in index:
|
|
100
|
+
index.remove(tree_id)
|
|
101
|
+
self._write_index(self.tree_dir, index)
|
|
102
|
+
|
|
103
|
+
return True
|
|
104
|
+
except Exception:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
async def list_trees(self) -> list[str]:
|
|
108
|
+
return self._read_index(self.tree_dir)
|
|
109
|
+
|
|
110
|
+
async def save_workflow(self, workflow: Workflow) -> bool:
|
|
111
|
+
try:
|
|
112
|
+
path = self._workflow_path(workflow.id)
|
|
113
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
114
|
+
f.write(workflow.model_dump_json(indent=2))
|
|
115
|
+
|
|
116
|
+
index = self._read_index(self.workflow_dir)
|
|
117
|
+
if workflow.id not in index:
|
|
118
|
+
index.append(workflow.id)
|
|
119
|
+
self._write_index(self.workflow_dir, index)
|
|
120
|
+
|
|
121
|
+
return True
|
|
122
|
+
except Exception:
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
async def load_workflow(self, workflow_id: str) -> Optional[Workflow]:
|
|
126
|
+
try:
|
|
127
|
+
path = self._workflow_path(workflow_id)
|
|
128
|
+
if not path.exists():
|
|
129
|
+
return None
|
|
130
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
131
|
+
return Workflow.model_validate_json(f.read())
|
|
132
|
+
except Exception:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
async def delete_workflow(self, workflow_id: str) -> bool:
|
|
136
|
+
try:
|
|
137
|
+
path = self._workflow_path(workflow_id)
|
|
138
|
+
if path.exists():
|
|
139
|
+
path.unlink()
|
|
140
|
+
|
|
141
|
+
index = self._read_index(self.workflow_dir)
|
|
142
|
+
if workflow_id in index:
|
|
143
|
+
index.remove(workflow_id)
|
|
144
|
+
self._write_index(self.workflow_dir, index)
|
|
145
|
+
|
|
146
|
+
return True
|
|
147
|
+
except Exception:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
async def list_workflows(self) -> list[str]:
|
|
151
|
+
return self._read_index(self.workflow_dir)
|
|
152
|
+
|
|
153
|
+
async def save_work_log(self, log: WorkLog) -> bool:
|
|
154
|
+
try:
|
|
155
|
+
path = self._work_log_path(log.id)
|
|
156
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
157
|
+
f.write(log.model_dump_json(indent=2))
|
|
158
|
+
|
|
159
|
+
index = self._read_index(self.work_log_dir)
|
|
160
|
+
if log.id not in index:
|
|
161
|
+
index.append(log.id)
|
|
162
|
+
self._write_index(self.work_log_dir, index)
|
|
163
|
+
|
|
164
|
+
return True
|
|
165
|
+
except Exception:
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
async def load_work_log(self, log_id: str) -> Optional[WorkLog]:
|
|
169
|
+
try:
|
|
170
|
+
path = self._work_log_path(log_id)
|
|
171
|
+
if not path.exists():
|
|
172
|
+
return None
|
|
173
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
174
|
+
return WorkLog.model_validate_json(f.read())
|
|
175
|
+
except Exception:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
async def delete_work_log(self, log_id: str) -> bool:
|
|
179
|
+
try:
|
|
180
|
+
path = self._work_log_path(log_id)
|
|
181
|
+
if path.exists():
|
|
182
|
+
path.unlink()
|
|
183
|
+
|
|
184
|
+
index = self._read_index(self.work_log_dir)
|
|
185
|
+
if log_id in index:
|
|
186
|
+
index.remove(log_id)
|
|
187
|
+
self._write_index(self.work_log_dir, index)
|
|
188
|
+
|
|
189
|
+
return True
|
|
190
|
+
except Exception:
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
async def list_work_logs(
|
|
194
|
+
self,
|
|
195
|
+
performed_by: Optional[str] = None,
|
|
196
|
+
source: Optional[str] = None,
|
|
197
|
+
since: Optional[datetime] = None,
|
|
198
|
+
project_path: Optional[str] = None,
|
|
199
|
+
limit: int = 100,
|
|
200
|
+
) -> list[WorkLog]:
|
|
201
|
+
logs = []
|
|
202
|
+
index = self._read_index(self.work_log_dir)
|
|
203
|
+
|
|
204
|
+
for log_id in index:
|
|
205
|
+
log = await self.load_work_log(log_id)
|
|
206
|
+
if log is None:
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
if performed_by and log.performed_by != performed_by:
|
|
210
|
+
continue
|
|
211
|
+
if source and log.source != source:
|
|
212
|
+
continue
|
|
213
|
+
if since and log.performed_at < since:
|
|
214
|
+
continue
|
|
215
|
+
if project_path and log.project_path != project_path:
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
logs.append(log)
|
|
219
|
+
|
|
220
|
+
logs.sort(key=lambda x: x.performed_at, reverse=True)
|
|
221
|
+
return logs[:limit]
|
|
222
|
+
|
|
223
|
+
async def search_work_logs(
|
|
224
|
+
self,
|
|
225
|
+
query: str,
|
|
226
|
+
project_path: Optional[str] = None,
|
|
227
|
+
limit: int = 50,
|
|
228
|
+
) -> list[WorkLog]:
|
|
229
|
+
query_lower = query.lower()
|
|
230
|
+
logs = []
|
|
231
|
+
index = self._read_index(self.work_log_dir)
|
|
232
|
+
|
|
233
|
+
for log_id in index:
|
|
234
|
+
log = await self.load_work_log(log_id)
|
|
235
|
+
if log is None:
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
# Project filter first (narrow down before text search)
|
|
239
|
+
if project_path and log.project_path != project_path:
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
searchable = " ".join([
|
|
243
|
+
log.request_text or "",
|
|
244
|
+
log.context or "",
|
|
245
|
+
" ".join(log.tags),
|
|
246
|
+
log.result_ref or "",
|
|
247
|
+
log.performed_by,
|
|
248
|
+
log.category or "",
|
|
249
|
+
log.project_path or "",
|
|
250
|
+
]).lower()
|
|
251
|
+
|
|
252
|
+
if query_lower in searchable:
|
|
253
|
+
logs.append(log)
|
|
254
|
+
|
|
255
|
+
logs.sort(key=lambda x: x.performed_at, reverse=True)
|
|
256
|
+
return logs[:limit]
|
|
257
|
+
|
|
258
|
+
async def list_project_paths(self) -> list[str]:
|
|
259
|
+
"""Return all unique project paths that have work logs."""
|
|
260
|
+
paths: set[str] = set()
|
|
261
|
+
index = self._read_index(self.work_log_dir)
|
|
262
|
+
|
|
263
|
+
for log_id in index:
|
|
264
|
+
log = await self.load_work_log(log_id)
|
|
265
|
+
if log and log.project_path:
|
|
266
|
+
paths.add(log.project_path)
|
|
267
|
+
|
|
268
|
+
return sorted(paths)
|
|
269
|
+
|
|
270
|
+
async def get_project_summary(self, project_path: str) -> dict:
|
|
271
|
+
"""Get aggregated statistics for a specific project."""
|
|
272
|
+
index = self._read_index(self.work_log_dir)
|
|
273
|
+
|
|
274
|
+
total_logs = 0
|
|
275
|
+
ai_sessions = 0
|
|
276
|
+
files_modified: set[str] = set()
|
|
277
|
+
commands_run: list[str] = []
|
|
278
|
+
tags: dict[str, int] = {}
|
|
279
|
+
performers: dict[str, int] = {}
|
|
280
|
+
last_activity: Optional[datetime] = None
|
|
281
|
+
|
|
282
|
+
for log_id in index:
|
|
283
|
+
log = await self.load_work_log(log_id)
|
|
284
|
+
if log is None or log.project_path != project_path:
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
total_logs += 1
|
|
288
|
+
|
|
289
|
+
if log.source == "ai_session":
|
|
290
|
+
ai_sessions += 1
|
|
291
|
+
|
|
292
|
+
if log.result_data:
|
|
293
|
+
for f in log.result_data.get("files_modified", []):
|
|
294
|
+
files_modified.add(f)
|
|
295
|
+
commands_run.extend(log.result_data.get("commands_run", []))
|
|
296
|
+
|
|
297
|
+
for tag in log.tags:
|
|
298
|
+
tags[tag] = tags.get(tag, 0) + 1
|
|
299
|
+
|
|
300
|
+
performers[log.performed_by] = performers.get(log.performed_by, 0) + 1
|
|
301
|
+
|
|
302
|
+
if last_activity is None or log.performed_at > last_activity:
|
|
303
|
+
last_activity = log.performed_at
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
"project_path": project_path,
|
|
307
|
+
"total_logs": total_logs,
|
|
308
|
+
"ai_sessions": ai_sessions,
|
|
309
|
+
"files_modified": sorted(files_modified),
|
|
310
|
+
"commands_run": commands_run,
|
|
311
|
+
"tags": dict(sorted(tags.items(), key=lambda x: x[1], reverse=True)),
|
|
312
|
+
"performers": performers,
|
|
313
|
+
"last_activity": last_activity.isoformat() if last_activity else None,
|
|
314
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Redis storage backend with ReJSON support."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Optional
|
|
5
|
+
import redis.asyncio as redis
|
|
6
|
+
from redis.asyncio.connection import ConnectionPool
|
|
7
|
+
|
|
8
|
+
from .base import BaseStorage
|
|
9
|
+
from ..models.tree import KnowledgeTree
|
|
10
|
+
from ..models.workflow import Workflow
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RedisStorage(BaseStorage):
|
|
14
|
+
TREE_PREFIX = "hits:tree:"
|
|
15
|
+
WORKFLOW_PREFIX = "hits:workflow:"
|
|
16
|
+
TREE_LIST_KEY = "hits:trees"
|
|
17
|
+
WORKFLOW_LIST_KEY = "hits:workflows"
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
host: str = "localhost",
|
|
22
|
+
port: int = 6379,
|
|
23
|
+
db: int = 0,
|
|
24
|
+
password: Optional[str] = None,
|
|
25
|
+
pool: Optional[ConnectionPool] = None,
|
|
26
|
+
):
|
|
27
|
+
if pool:
|
|
28
|
+
self.client = redis.Redis(connection_pool=pool)
|
|
29
|
+
else:
|
|
30
|
+
self.client = redis.Redis(
|
|
31
|
+
host=host,
|
|
32
|
+
port=port,
|
|
33
|
+
db=db,
|
|
34
|
+
password=password,
|
|
35
|
+
decode_responses=True,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def _tree_key(self, tree_id: str) -> str:
|
|
39
|
+
return f"{self.TREE_PREFIX}{tree_id}"
|
|
40
|
+
|
|
41
|
+
def _workflow_key(self, workflow_id: str) -> str:
|
|
42
|
+
return f"{self.WORKFLOW_PREFIX}{workflow_id}"
|
|
43
|
+
|
|
44
|
+
async def ping(self) -> bool:
|
|
45
|
+
try:
|
|
46
|
+
return await self.client.ping()
|
|
47
|
+
except redis.ConnectionError:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
async def save_tree(self, tree: KnowledgeTree) -> bool:
|
|
51
|
+
try:
|
|
52
|
+
key = self._tree_key(tree.id)
|
|
53
|
+
data = tree.model_dump_json()
|
|
54
|
+
await self.client.set(key, data)
|
|
55
|
+
await self.client.sadd(self.TREE_LIST_KEY, tree.id)
|
|
56
|
+
return True
|
|
57
|
+
except Exception:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
async def load_tree(self, tree_id: str) -> Optional[KnowledgeTree]:
|
|
61
|
+
try:
|
|
62
|
+
key = self._tree_key(tree_id)
|
|
63
|
+
data = await self.client.get(key)
|
|
64
|
+
if not data:
|
|
65
|
+
return None
|
|
66
|
+
return KnowledgeTree.model_validate_json(data)
|
|
67
|
+
except Exception:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
async def delete_tree(self, tree_id: str) -> bool:
|
|
71
|
+
try:
|
|
72
|
+
key = self._tree_key(tree_id)
|
|
73
|
+
await self.client.delete(key)
|
|
74
|
+
await self.client.srem(self.TREE_LIST_KEY, tree_id)
|
|
75
|
+
return True
|
|
76
|
+
except Exception:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
async def list_trees(self) -> list[str]:
|
|
80
|
+
try:
|
|
81
|
+
members = await self.client.smembers(self.TREE_LIST_KEY)
|
|
82
|
+
return list(members) if members else []
|
|
83
|
+
except Exception:
|
|
84
|
+
return []
|
|
85
|
+
|
|
86
|
+
async def save_workflow(self, workflow: Workflow) -> bool:
|
|
87
|
+
try:
|
|
88
|
+
key = self._workflow_key(workflow.id)
|
|
89
|
+
data = workflow.model_dump_json()
|
|
90
|
+
await self.client.set(key, data)
|
|
91
|
+
await self.client.sadd(self.WORKFLOW_LIST_KEY, workflow.id)
|
|
92
|
+
return True
|
|
93
|
+
except Exception:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
async def load_workflow(self, workflow_id: str) -> Optional[Workflow]:
|
|
97
|
+
try:
|
|
98
|
+
key = self._workflow_key(workflow_id)
|
|
99
|
+
data = await self.client.get(key)
|
|
100
|
+
if not data:
|
|
101
|
+
return None
|
|
102
|
+
return Workflow.model_validate_json(data)
|
|
103
|
+
except Exception:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
async def delete_workflow(self, workflow_id: str) -> bool:
|
|
107
|
+
try:
|
|
108
|
+
key = self._workflow_key(workflow_id)
|
|
109
|
+
await self.client.delete(key)
|
|
110
|
+
await self.client.srem(self.WORKFLOW_LIST_KEY, workflow_id)
|
|
111
|
+
return True
|
|
112
|
+
except Exception:
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
async def list_workflows(self) -> list[str]:
|
|
116
|
+
try:
|
|
117
|
+
members = await self.client.smembers(self.WORKFLOW_LIST_KEY)
|
|
118
|
+
return list(members) if members else []
|
|
119
|
+
except Exception:
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
async def close(self) -> None:
|
|
123
|
+
await self.client.aclose()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--bg-primary: #0F0F1A;--bg-secondary: #1A1A2E;--bg-surface1: #1E1E32;--bg-surface2: #252540;--bg-surface3: #2D2D4A;--bg-hover: #333355;--bg-active: #3D3D60;--text-primary: #E8E8F0;--text-secondary: #A0A0B8;--text-muted: #707088;--accent-primary: #1565C0;--accent-secondary: #42A5F5;--accent-hover: #1976D2;--border-color: #2A2A44;--divider: #333355;--layer-why: #FF9800;--layer-how: #4CAF50;--layer-what: #2196F3;--success: #4CAF50;--danger: #F44336;--warning: #FF9800;--info: #2196F3;--shadow-sm: 0 1px 3px rgba(0,0,0,.3);--shadow-md: 0 4px 12px rgba(0,0,0,.4);--shadow-lg: 0 8px 24px rgba(0,0,0,.5);--radius-sm: 4px;--radius-md: 8px;--radius-lg: 12px;--font-sans: "Noto Sans KR", "Segoe UI", system-ui, -apple-system, sans-serif;--font-mono: "Fira Code", "Consolas", monospace}*{margin:0;padding:0;box-sizing:border-box}html,body,#app{height:100%;width:100%;overflow:hidden}body{font-family:var(--font-sans);background:var(--bg-primary);color:var(--text-primary);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:var(--bg-secondary)}::-webkit-scrollbar-thumb{background:var(--bg-surface3);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:var(--bg-hover)}.flex{display:flex}.flex-col{flex-direction:column}.items-center{align-items:center}.gap-sm{gap:8px}.gap-md{gap:16px}.gap-lg{gap:24px}.w-full{width:100%}.h-full{height:100%}.overflow-y{overflow-y:auto}.text-muted{color:var(--text-muted)}.text-sm{font-size:12px}.text-xs{font-size:11px}.mt-sm{margin-top:8px}.mt-md{margin-top:16px}.mb-sm{margin-bottom:8px}.p-sm{padding:8px}.p-md{padding:16px}.app-layout{display:flex;height:100vh;width:100vw}.sidebar{width:260px;min-width:260px;background:var(--bg-secondary);border-right:1px solid var(--border-color);display:flex;flex-direction:column;transition:width .2s,min-width .2s}.sidebar.collapsed{width:0;min-width:0;overflow:hidden}.main-content{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0}.header{height:52px;background:linear-gradient(135deg,var(--bg-surface1),var(--bg-surface2));border-bottom:1px solid var(--border-color);display:flex;align-items:center;padding:0 16px;gap:12px;flex-shrink:0}.header h1{font-size:16px;font-weight:600;background:linear-gradient(90deg,var(--accent-secondary),#90CAF9);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.tabs{display:flex;gap:2px;background:var(--bg-secondary);padding:4px 12px;border-bottom:1px solid var(--border-color);flex-shrink:0}.tab{padding:8px 16px;background:transparent;color:var(--text-secondary);border:none;border-radius:var(--radius-sm) var(--radius-sm) 0 0;cursor:pointer;font-size:13px;font-family:var(--font-sans);transition:all .15s}.tab:hover{background:var(--bg-surface1);color:var(--text-primary)}.tab.active{background:var(--bg-primary);color:var(--accent-secondary);border-bottom:2px solid var(--accent-primary)}.btn{padding:8px 16px;border:none;border-radius:var(--radius-sm);cursor:pointer;font-size:13px;font-family:var(--font-sans);font-weight:500;transition:all .15s;display:inline-flex;align-items:center;gap:6px}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-primary{background:var(--accent-primary);color:#fff}.btn-primary:hover:not(:disabled){background:var(--accent-hover)}.btn-secondary{background:var(--bg-surface2);color:var(--text-primary);border:1px solid var(--border-color)}.btn-secondary:hover:not(:disabled){background:var(--bg-surface3)}.btn-danger{background:var(--danger);color:#fff}.btn-danger:hover:not(:disabled){background:#d32f2f}.btn-sm{padding:4px 10px;font-size:12px}.btn-icon{width:32px;height:32px;padding:0;display:flex;align-items:center;justify-content:center;background:var(--bg-surface2);color:var(--text-secondary);border:1px solid var(--border-color);border-radius:var(--radius-sm);cursor:pointer;font-size:16px}.btn-icon:hover{background:var(--bg-surface3);color:var(--text-primary)}.input{width:100%;padding:8px 12px;background:var(--bg-surface1);color:var(--text-primary);border:1px solid var(--border-color);border-radius:var(--radius-sm);font-size:13px;font-family:var(--font-sans);outline:none;transition:border-color .15s}.input:focus{border-color:var(--accent-primary)}.input::placeholder{color:var(--text-muted)}textarea.input{resize:vertical;min-height:60px}select.input{cursor:pointer}.card{background:var(--bg-surface1);border:1px solid var(--border-color);border-radius:var(--radius-md);padding:12px 16px;transition:border-color .15s,box-shadow .15s}.card:hover{border-color:var(--bg-surface3);box-shadow:var(--shadow-sm)}.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:500}.badge-why{background:#ff980026;color:var(--layer-why)}.badge-how{background:#4caf5026;color:var(--layer-how)}.badge-what{background:#2196f326;color:var(--layer-what)}.modal-overlay{position:fixed;top:0;right:0;bottom:0;left:0;background:#0009;display:flex;align-items:center;justify-content:center;z-index:1000}.modal{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:var(--radius-lg);padding:24px;width:90%;max-width:480px;max-height:80vh;overflow-y:auto;box-shadow:var(--shadow-lg)}.modal h2{font-size:16px;margin-bottom:16px;color:var(--text-primary)}.login-container{display:flex;align-items:center;justify-content:center;height:100vh;background:linear-gradient(135deg,#0a0a1a,#1a1a3e)}.login-card{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:var(--radius-lg);padding:32px;width:90%;max-width:400px;box-shadow:var(--shadow-lg)}.login-card h1{text-align:center;margin-bottom:8px;font-size:20px}.login-card .subtitle{text-align:center;color:var(--text-muted);margin-bottom:24px;font-size:13px}.form-group{margin-bottom:16px}.form-group label{display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px;font-weight:500}.error-msg{color:var(--danger);font-size:12px;margin-top:8px}.sidebar-section{padding:12px;border-bottom:1px solid var(--border-color)}.sidebar-section h3{font-size:11px;text-transform:uppercase;color:var(--text-muted);letter-spacing:.5px;margin-bottom:8px}.project-item{padding:6px 8px;border-radius:var(--radius-sm);cursor:pointer;font-size:12px;color:var(--text-secondary);transition:all .1s;display:flex;align-items:center;gap:6px}.project-item:hover{background:var(--bg-surface2);color:var(--text-primary)}.project-item.active{background:var(--accent-primary);color:#fff}.category-header{display:flex;align-items:center;padding:10px 16px;background:var(--bg-surface1);border:1px solid var(--border-color);border-radius:var(--radius-md);cursor:pointer;gap:8px;transition:background .1s;margin-bottom:4px}.category-header:hover{background:var(--bg-surface2)}.category-header .icon{font-size:18px}.category-header .name{flex:1;font-size:13px;font-weight:500}.category-header .count{font-size:11px;color:var(--text-muted)}.category-header .actions{display:flex;gap:4px;opacity:0;transition:opacity .15s}.category-header:hover .actions{opacity:1}.node-item{display:flex;align-items:center;padding:8px 16px 8px 36px;gap:8px;border-left:2px solid transparent;transition:all .1s}.node-item:hover{background:var(--bg-surface1)}.node-item.layer-why{border-left-color:var(--layer-why)}.node-item.layer-how{border-left-color:var(--layer-how)}.node-item.layer-what{border-left-color:var(--layer-what)}.node-item .node-name{flex:1;font-size:13px}.node-item .node-action{font-size:11px;color:var(--text-muted);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.node-item.negative-path{opacity:.7}.node-item.negative-path:before{content:"⚠";font-size:12px}.timeline-item{padding:12px 16px;border-bottom:1px solid var(--border-color);transition:background .1s;cursor:pointer}.timeline-item:hover{background:var(--bg-surface1)}.timeline-date{padding:8px 16px;font-size:12px;font-weight:600;color:var(--text-muted);background:var(--bg-secondary);position:sticky;top:0;z-index:10;border-bottom:1px solid var(--border-color)}.timeline-item .meta{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--text-muted);margin-top:4px}.timeline-item .summary{font-size:13px;color:var(--text-primary)}.timeline-item .tags{display:flex;gap:4px;margin-top:4px;flex-wrap:wrap}.tag{padding:1px 6px;background:var(--bg-surface3);border-radius:8px;font-size:10px;color:var(--text-secondary)}.handover-section{margin-bottom:20px}.handover-section h3{font-size:14px;font-weight:600;margin-bottom:8px;color:var(--accent-secondary)}.handover-item{padding:6px 12px;border-left:2px solid var(--divider);margin-left:8px;font-size:13px;color:var(--text-secondary);margin-bottom:4px}.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:48px;color:var(--text-muted)}.empty-state .icon{font-size:48px;margin-bottom:16px}.empty-state .message{font-size:14px;margin-bottom:16px}.loading{display:flex;align-items:center;justify-content:center;padding:24px;color:var(--text-muted)}.spinner{width:20px;height:20px;border:2px solid var(--bg-surface3);border-top:2px solid var(--accent-secondary);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.user-menu{position:relative;margin-left:auto}.user-menu-toggle{display:flex;align-items:center;gap:6px;padding:4px 8px;border-radius:var(--radius-sm);cursor:pointer;color:var(--text-secondary);font-size:13px;background:none;border:none;font-family:var(--font-sans)}.user-menu-toggle:hover{background:var(--bg-surface2);color:var(--text-primary)}.user-dropdown{position:absolute;top:100%;right:0;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:var(--radius-md);padding:4px;min-width:160px;box-shadow:var(--shadow-md);z-index:100}.user-dropdown button{display:block;width:100%;text-align:left;padding:8px 12px;background:none;border:none;color:var(--text-secondary);font-size:13px;font-family:var(--font-sans);cursor:pointer;border-radius:var(--radius-sm)}.user-dropdown button:hover{background:var(--bg-surface2);color:var(--text-primary)}.content-area{flex:1;overflow-y:auto;padding:16px}
|