ai_co_discuss 0.3.96 → 0.3.97
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/backend/schema.sql +3 -1
- package/backend/server.py +50 -8
- package/package.json +1 -1
- package/web/app.js +20 -12
- package/web/modern.css +1 -1
- package/web/modern.js +31 -31
package/backend/schema.sql
CHANGED
|
@@ -15,8 +15,10 @@ BEGIN
|
|
|
15
15
|
INSERT INTO room_directory (room_id,position)
|
|
16
16
|
VALUES (NEW.id,(SELECT COALESCE(MAX(position),0)+1 FROM room_directory));
|
|
17
17
|
END;
|
|
18
|
+
-- `directories` is a JSON array of absolute paths: the first is the main project directory of rooms created in the
|
|
19
|
+
-- folder, the rest their other project directories. Rooms keep their own copy; editing the folder only changes new rooms.
|
|
18
20
|
CREATE TABLE IF NOT EXISTS topic_folders (
|
|
19
|
-
id TEXT PRIMARY KEY, name TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0
|
|
21
|
+
id TEXT PRIMARY KEY, name TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0, directories TEXT NOT NULL DEFAULT '[]'
|
|
20
22
|
);
|
|
21
23
|
-- Site-wide preferences of the person managing the site; `last_room` is the room they last opened on any device.
|
|
22
24
|
CREATE TABLE IF NOT EXISTS site_preferences (
|
package/backend/server.py
CHANGED
|
@@ -80,6 +80,10 @@ PAGE_SIZE = 20
|
|
|
80
80
|
# A reader may ask for larger pages (`limit`) so a long transcript loads in one request instead of many.
|
|
81
81
|
MAX_PAGE_SIZE = 200
|
|
82
82
|
UNCATEGORIZED_FOLDER_ID = "__uncategorized__"
|
|
83
|
+
# A folder's project paths: one main directory plus MAX_EXTRA_DIRECTORIES (runner/discussion_runner.mjs) others.
|
|
84
|
+
MAX_FOLDER_DIRECTORIES = 9
|
|
85
|
+
MAX_DIRECTORY_PATH = 1000
|
|
86
|
+
ABSOLUTE_PATH_PATTERN = re.compile(r"(?:[A-Za-z]:[\\/]|/)")
|
|
83
87
|
ASSETS = {"/": ("index.html", "text/html"),
|
|
84
88
|
"/app.js": ("app.js", "text/javascript"),
|
|
85
89
|
"/modern.js": ("modern.js", "text/javascript"),
|
|
@@ -348,6 +352,8 @@ def init_database(database, seed=True):
|
|
|
348
352
|
if "changes" not in columns:
|
|
349
353
|
connection.execute("ALTER TABLE member_executions ADD COLUMN changes TEXT NOT NULL DEFAULT ''")
|
|
350
354
|
connection.executescript(SCHEMA)
|
|
355
|
+
if "directories" not in {row[1] for row in connection.execute("PRAGMA table_info(topic_folders)")}:
|
|
356
|
+
connection.execute("ALTER TABLE topic_folders ADD COLUMN directories TEXT NOT NULL DEFAULT '[]'")
|
|
351
357
|
migrate_free_tables(connection)
|
|
352
358
|
if seed and is_new:
|
|
353
359
|
seed_demo(connection)
|
|
@@ -722,11 +728,40 @@ def remember_last_room(connection, payload):
|
|
|
722
728
|
return {"last_room": room_id}
|
|
723
729
|
|
|
724
730
|
|
|
731
|
+
def folder_record(row):
|
|
732
|
+
"""Return one folder with its project paths decoded."""
|
|
733
|
+
return {**dict(row), "directories": json.loads(row["directories"])}
|
|
734
|
+
|
|
735
|
+
|
|
725
736
|
def folder_directory(connection):
|
|
726
737
|
"""Return real folders and the virtual group's index (null keeps it last)."""
|
|
727
738
|
settings = connection.execute("SELECT uncategorized_position FROM directory_settings WHERE id=1").fetchone()
|
|
728
|
-
|
|
729
|
-
|
|
739
|
+
rows = connection.execute("SELECT id,name,position,directories FROM topic_folders ORDER BY position,name,id")
|
|
740
|
+
return {"folders": [folder_record(row) for row in rows], "uncategorized_position": settings[0] if settings else None}
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def same_path_key(path):
|
|
744
|
+
"""Compare paths as Windows does: without case, separator style or a trailing separator."""
|
|
745
|
+
return path.replace("\\", "/").rstrip("/").lower()
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def folder_directories_value(payload):
|
|
749
|
+
"""Validate a folder's project paths: absolute, distinct, the first being the main directory.
|
|
750
|
+
|
|
751
|
+
The paths are only checked for form here; the local service checks they exist when a room is created from them.
|
|
752
|
+
"""
|
|
753
|
+
directories = payload.get("directories", [])
|
|
754
|
+
if not isinstance(directories, list) or len(directories) > MAX_FOLDER_DIRECTORIES:
|
|
755
|
+
raise ValueError(f"项目路径须为最多 {MAX_FOLDER_DIRECTORIES} 项的列表")
|
|
756
|
+
paths = []
|
|
757
|
+
for item in directories:
|
|
758
|
+
path = item.strip() if isinstance(item, str) else ""
|
|
759
|
+
if not path or len(path) > MAX_DIRECTORY_PATH or not ABSOLUTE_PATH_PATTERN.match(path):
|
|
760
|
+
raise ValueError("项目路径须为完整的绝对路径")
|
|
761
|
+
if same_path_key(path) in [same_path_key(known) for known in paths]:
|
|
762
|
+
raise ValueError(f"项目路径重复:{path}")
|
|
763
|
+
paths.append(path)
|
|
764
|
+
return paths
|
|
730
765
|
|
|
731
766
|
|
|
732
767
|
def folder_name_value(payload):
|
|
@@ -741,12 +776,16 @@ def folder_name_value(payload):
|
|
|
741
776
|
|
|
742
777
|
|
|
743
778
|
def rename_folder(connection, folder_id, payload):
|
|
744
|
-
"""Rename one folder,
|
|
779
|
+
"""Rename one folder and, when given, replace its project paths; positions and room assignments stay."""
|
|
745
780
|
begin_write(connection)
|
|
746
781
|
name = folder_name_value(payload)
|
|
747
|
-
|
|
782
|
+
changes = {"name": name}
|
|
783
|
+
if "directories" in payload:
|
|
784
|
+
changes["directories"] = json.dumps(folder_directories_value(payload), ensure_ascii=False)
|
|
785
|
+
assignments = ",".join(f"{column}=?" for column in changes)
|
|
786
|
+
if connection.execute(f"UPDATE topic_folders SET {assignments} WHERE id=?", (*changes.values(), folder_id)).rowcount == 0:
|
|
748
787
|
raise LookupError("文件夹不存在")
|
|
749
|
-
return
|
|
788
|
+
return folder_record(connection.execute("SELECT id,name,position,directories FROM topic_folders WHERE id=?", (folder_id,)).fetchone())
|
|
750
789
|
|
|
751
790
|
|
|
752
791
|
def delete_folder(connection, folder_id):
|
|
@@ -2168,12 +2207,15 @@ class DiscussionHandler(BaseHTTPRequestHandler):
|
|
|
2168
2207
|
connection.commit()
|
|
2169
2208
|
return self.respond(result, 200 if result.get("skipped") else 201)
|
|
2170
2209
|
if parts == ["api", "folders"] and method == "POST":
|
|
2171
|
-
|
|
2210
|
+
payload = self.read_payload()
|
|
2211
|
+
name = folder_name_value(payload)
|
|
2212
|
+
directories = folder_directories_value(payload)
|
|
2172
2213
|
folder_id = uuid.uuid4().hex
|
|
2173
2214
|
position = connection.execute("SELECT COALESCE(MAX(position),0)+1 FROM topic_folders").fetchone()[0]
|
|
2174
|
-
connection.execute("INSERT INTO topic_folders (id,name,position) VALUES (
|
|
2215
|
+
connection.execute("INSERT INTO topic_folders (id,name,position,directories) VALUES (?,?,?,?)",
|
|
2216
|
+
(folder_id, name, position, json.dumps(directories, ensure_ascii=False)))
|
|
2175
2217
|
connection.commit()
|
|
2176
|
-
return self.respond({"id": folder_id, "name": name, "position": position}, 201)
|
|
2218
|
+
return self.respond({"id": folder_id, "name": name, "position": position, "directories": directories}, 201)
|
|
2177
2219
|
if len(parts) == 4 and parts[:2] == ["api", "folders"] and parts[3] == "move" and method == "POST":
|
|
2178
2220
|
result = move_folder(connection, parts[2], self.read_payload())
|
|
2179
2221
|
connection.commit()
|
package/package.json
CHANGED
package/web/app.js
CHANGED
|
@@ -716,24 +716,24 @@ async function assign_channel_folder(id, folder_id) {
|
|
|
716
716
|
}
|
|
717
717
|
|
|
718
718
|
/** Display a provisional folder while its identifier is being assigned. */
|
|
719
|
-
async function create_folder(name) {
|
|
720
|
-
const folder = { id: `pending-${new_submission_id()}`, name: name.trim(), pending: true };
|
|
719
|
+
async function create_folder({ name, directories = [] }) {
|
|
720
|
+
const folder = { id: `pending-${new_submission_id()}`, name: name.trim(), directories, pending: true };
|
|
721
721
|
return update_directory({ key: "create-folder", label: `文件夹「${folder.name}」创建`,
|
|
722
722
|
apply: () => { state.folders = [...state.folders, folder]; },
|
|
723
723
|
rollback: () => { state.folders = state.folders.filter(item => item.id !== folder.id); },
|
|
724
|
-
save: () => request_json("/api/folders", { name: folder.name }),
|
|
724
|
+
save: () => request_json("/api/folders", { name: folder.name, directories }),
|
|
725
725
|
commit: saved => { state.folders = state.folders.map(item => item.id === folder.id ? saved : item); } });
|
|
726
726
|
}
|
|
727
727
|
|
|
728
|
-
/** Rename the exact folder without moving its conversations or changing selection. */
|
|
729
|
-
async function rename_folder({ id, name }) {
|
|
728
|
+
/** Rename the exact folder and replace its project paths, without moving its conversations or changing selection. */
|
|
729
|
+
async function rename_folder({ id, name, directories }) {
|
|
730
730
|
const folder = state.folders.find(item => item.id === id);
|
|
731
731
|
if (!folder || folder.pending || !name.trim()) return;
|
|
732
|
-
const display = value => { state.folders = state.folders.map(item => item.id === id ? { ...item, name: value } : item); };
|
|
733
|
-
return update_directory({ key: `folder-name:${id}`, label: `文件夹「${folder.name}
|
|
734
|
-
apply: () => display(name.trim()), rollback: () => display(folder.name),
|
|
735
|
-
save: () => request_json(`/api/folders/${encodeURIComponent(id)}/rename`, { name: name.trim() }),
|
|
736
|
-
commit: saved => display(saved.name) });
|
|
732
|
+
const display = (value, paths) => { state.folders = state.folders.map(item => item.id === id ? { ...item, name: value, directories: paths } : item); };
|
|
733
|
+
return update_directory({ key: `folder-name:${id}`, label: `文件夹「${folder.name}」保存`,
|
|
734
|
+
apply: () => display(name.trim(), directories), rollback: () => display(folder.name, folder.directories),
|
|
735
|
+
save: () => request_json(`/api/folders/${encodeURIComponent(id)}/rename`, { name: name.trim(), directories }),
|
|
736
|
+
commit: saved => display(saved.name, saved.directories) });
|
|
737
737
|
}
|
|
738
738
|
|
|
739
739
|
/** Remove only a grouping; keep its conversations, current transcript and drafts. */
|
|
@@ -1158,11 +1158,11 @@ function mentioned(text, name) {
|
|
|
1158
1158
|
}
|
|
1159
1159
|
|
|
1160
1160
|
/** Clear this room's text, project and roster, then restore the user's last reusable choices. */
|
|
1161
|
-
function reset_create_form() {
|
|
1161
|
+
function reset_create_form(folder = null) {
|
|
1162
1162
|
element("room-form").reset();
|
|
1163
1163
|
render_expert_inputs("new-expert-modes", default_experts());
|
|
1164
1164
|
render_host_options("new-room-host", [], "");
|
|
1165
|
-
state.launch.workspace =
|
|
1165
|
+
state.launch.workspace = folder_workspace(folder);
|
|
1166
1166
|
state.create_goal = null;
|
|
1167
1167
|
apply_create_preferences();
|
|
1168
1168
|
state.launch.profiles = JSON.parse(JSON.stringify(state.launch.saved_profiles || {}));
|
|
@@ -1172,6 +1172,14 @@ function reset_create_form() {
|
|
|
1172
1172
|
return refresh_create_preferences();
|
|
1173
1173
|
}
|
|
1174
1174
|
|
|
1175
|
+
/** A room created in a folder starts in the folder's project paths: the first is its main directory, the rest its other
|
|
1176
|
+
* directories. Only the local service resolves paths, so a device reaching its computer through the queued relay starts empty. */
|
|
1177
|
+
function folder_workspace(folder) {
|
|
1178
|
+
const [cwd, ...extra_directories] = folder?.directories || [];
|
|
1179
|
+
if (!cwd || !state.launch.available) return {};
|
|
1180
|
+
return extra_directories.length ? { cwd, extra_directories } : { cwd };
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1175
1183
|
/** Restore choices without touching the new room's title, project, members, host or goal text. */
|
|
1176
1184
|
function apply_create_preferences() {
|
|
1177
1185
|
const preferences = state.launch.create_preferences || {};
|