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.
@@ -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
- return {"folders": [dict(row) for row in connection.execute("SELECT id,name,position FROM topic_folders ORDER BY position,name,id")],
729
- "uncategorized_position": settings[0] if settings else None}
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, retaining its position and all room assignments."""
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
- if connection.execute("UPDATE topic_folders SET name=? WHERE id=?", (name, folder_id)).rowcount == 0:
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 dict(connection.execute("SELECT id,name,position FROM topic_folders WHERE id=?", (folder_id,)).fetchone())
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
- name = folder_name_value(self.read_payload())
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 (?,?,?)", (folder_id, name, position))
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.3.96",
3
+ "version": "0.3.97",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
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 || {};