ai_co_discuss 0.3.30 → 0.3.32

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.
@@ -521,7 +521,7 @@ def describe_members(connection, room_id, token, payload):
521
521
 
522
522
 
523
523
  def member_labels(connection, room_id):
524
- """The owner's display labels of members by name; only screens use them, never addressing or credentials."""
524
+ """The owner's display labels of members by name; screens show them and the page turns a user's @label into the member name, never credentials."""
525
525
  return {row["name"]: row["label"] for row in connection.execute(
526
526
  "SELECT name,label FROM member_labels WHERE room_id=? ORDER BY name", (room_id,))}
527
527
 
@@ -1699,6 +1699,8 @@ def execution_action(connection, room_id, execution_id, token, payload):
1699
1699
  connection.execute("""INSERT INTO execution_capabilities (execution_id,room_id,stoppable,progress) VALUES (?,?,?,?)
1700
1700
  ON CONFLICT(execution_id) DO UPDATE SET stoppable=excluded.stoppable,progress=excluded.progress""",
1701
1701
  (execution_id, room_id, int(payload["stoppable"]), int(payload["progress"])))
1702
+ elif action == "retry":
1703
+ retry_failed(connection, room_id, row)
1702
1704
  elif row["status"] not in ACTIVE:
1703
1705
  raise FileExistsError("执行已结束")
1704
1706
  elif action == "renew":
@@ -1745,11 +1747,35 @@ def execution_action(connection, room_id, execution_id, token, payload):
1745
1747
  # Only for proven non-delivery: the requests return to the queue unchanged.
1746
1748
  finish_execution(connection, execution_id, "cancelled", str(payload.get("reason") or "未送达"), "pending")
1747
1749
  else:
1748
- raise ValueError("action 须为 renew、fail、release、report、segments、retry_wait、retry_start、retry_now 或 retry_stop")
1750
+ raise ValueError("action 须为 renew、fail、release、report、segments、retry_wait、retry_start、retry_now、retry_stop 或 retry")
1749
1751
  detail = execution_detail(connection, connection.execute("SELECT * FROM member_executions WHERE id=?", (execution_id,)).fetchone())
1750
1752
  return {**detail, "duplicate": duplicate} if action == "stopped" else detail
1751
1753
 
1752
1754
 
1755
+ def retry_failed(connection, room_id, execution):
1756
+ """Owner retry of a failed execution: its requests go back to the same member's queue and the runner delivers them anew.
1757
+
1758
+ The failed execution stays as history but no longer carries the requests, so a second retry is refused. A host batch
1759
+ that failed as a whole reopens and its chain resumes, so the host still gets the answer back.
1760
+ """
1761
+ if execution["status"] != "failed":
1762
+ raise FileExistsError("只有失败的执行可以重试")
1763
+ requests = connection.execute("""SELECT id,batch_id,chain_id FROM member_requests r WHERE execution_id=? AND status='failed'
1764
+ AND NOT EXISTS(SELECT 1 FROM session_requests s WHERE s.member_request_id=r.id)""", (execution["id"],)).fetchall()
1765
+ if not requests:
1766
+ raise FileExistsError("这次执行没有可重试的请求:已重试过,或是讨论发言(由主持人重新安排)")
1767
+ if not connection.execute("SELECT 1 FROM room_members WHERE id=? AND status='active'", (execution["member_id"],)).fetchone():
1768
+ raise FileExistsError("该成员已离开或暂离,无法重试")
1769
+ connection.execute(f"UPDATE member_requests SET status='pending',execution_id=NULL WHERE id IN ({','.join('?' * len(requests))})",
1770
+ [request["id"] for request in requests])
1771
+ for request in requests:
1772
+ batch_id, chain_id = request["batch_id"], request["chain_id"]
1773
+ if batch_id and not connection.execute(
1774
+ "SELECT 1 FROM member_requests WHERE room_id=? AND batch_id=? AND status='completed'", (room_id, batch_id)).fetchone():
1775
+ connection.execute("UPDATE free_batches SET settled=0 WHERE room_id=? AND batch_id=?", (room_id, batch_id))
1776
+ connection.execute("DELETE FROM free_chains WHERE room_id=? AND chain_id=? AND reason='all_failed'", (room_id, chain_id))
1777
+
1778
+
1753
1779
  def owned_member(connection, room_id, member_id, token):
1754
1780
  """Read one member after proving the owner's credential."""
1755
1781
  require_owner(connection, room_id, token)
package/docs/workflow.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  ## 自由协作房间
17
17
 
18
- - **点名**:`@成员` 后,它在自己的原会话中处理并回复,多位可并行。
18
+ - **点名**:`@成员` 后,它在自己的原会话中处理并回复,多位可并行。用界面上显示的昵称点名即可(如 `@Gemini`),输入 `@` 后从列表选择最方便。
19
19
  - **未点名的消息**:交给主持人分派;只有一位成员时直接交给它。
20
20
  - **主持人派活**:主持人点名一位成员时,该成员直接回复你;主持人需要接着安排(如做完再交评审)时会要求它做完交回,点名多位时都完成后主持人再汇总。你的一条消息最多让主持人推进 3 轮(主持人派活、成员回答、交回汇总为一轮),建房时在“推进方式”里或建房后在房间页的“协作设置”里调整(1–20 轮);到上限会停下等你,右栏点“继续”即接着推进。
21
21
  - **全体回答**:`@全体成员` 请所有成员各自回答,`@全体依次` 按发言顺序逐个回答。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.3.30",
3
+ "version": "0.3.32",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
package/web/app.js CHANGED
@@ -947,7 +947,7 @@ async function refresh_room({ room: read_room = true } = {}) {
947
947
  }
948
948
  if (state.room && !state.rooms.some((item) => item.id === state.room.id)) return select_room(visible_rooms()[0]?.id);
949
949
  if (!state.room || !read_room) {
950
- if (!state.room && visible_rooms().length) return select_room(visible_rooms()[0].id);
950
+ if (!state.room && visible_rooms().length) return select_room(opening_room());
951
951
  render_rooms();
952
952
  set_connection(true);
953
953
  return;
@@ -964,6 +964,17 @@ async function refresh_room({ room: read_room = true } = {}) {
964
964
  }
965
965
 
966
966
  /** Select a room and reset room-specific draft and filter state. */
967
+ // The room last opened in this browser, reopened on the next visit instead of the first in the list.
968
+ const LAST_ROOM_KEY = "ai-codiscuss-last-room";
969
+
970
+ /** The room to open when none is chosen: the address's room, else the one last opened here, else the first listed. */
971
+ function opening_room() {
972
+ let last = null;
973
+ try { last = localStorage.getItem(LAST_ROOM_KEY); } catch { /* Storage may be unavailable. */ }
974
+ const wanted = [new URLSearchParams(location.search).get("room"), last].find((id) => id && state.rooms.some((room) => room.id === id));
975
+ return wanted || visible_rooms()[0]?.id;
976
+ }
977
+
967
978
  async function select_room(room_id) {
968
979
  state.navigation_version++;
969
980
  state.refresh_version++;
@@ -991,6 +1002,7 @@ async function select_room(room_id) {
991
1002
  cancel_reply();
992
1003
  select_tab("discussion", { refresh: false });
993
1004
  history.replaceState(null, "", state.room ? `/?room=${encodeURIComponent(room_id)}` : "/");
1005
+ if (state.room) try { localStorage.setItem(LAST_ROOM_KEY, room_id); } catch { /* Keep working without remembering. */ }
994
1006
  if (state.room) state.archive_view = Boolean(state.room.archived);
995
1007
  else {
996
1008
  element("host-brief-panel").hidden = true;
@@ -1036,10 +1048,13 @@ function message_payload() {
1036
1048
  const members = state.room.free?.members || [];
1037
1049
  const payload = { author: "用户", text, reply_to: state.reply_to, kind: "chat", submitted_by: "用户" };
1038
1050
  const active = members.filter(member => member.status === "active" && (member.access !== "manual" || member.registered_at)).map(member => member.name);
1051
+ // Members are named by the nickname shown on screen (or their name); the request carries the member name.
1052
+ const named_here = mentioned_members(text, active);
1053
+ const mentioned_member = name => named_here.has(name);
1039
1054
  const session = state.room.session;
1040
1055
  const session_live = session && ["active", "paused"].includes(session.status);
1041
1056
  if (session_live) {
1042
- const direct = active.filter(name => mentioned(text, name));
1057
+ const direct = active.filter(mentioned_member);
1043
1058
  if (direct.length) payload.to = direct;
1044
1059
  return payload;
1045
1060
  }
@@ -1047,7 +1062,7 @@ function message_payload() {
1047
1062
  // @全体依次 or @all-seq has them answer one after another, the host first); without any, the server gives the message to the only member or the host.
1048
1063
  const sequential = mentioned(text, "全体依次") || mentioned(text, "all-seq");
1049
1064
  const everyone = !sequential && (mentioned(text, "全体成员") || mentioned(text, "all"));
1050
- const named = sequential || everyone ? active : active.filter(name => mentioned(text, name));
1065
+ const named = sequential || everyone ? active : active.filter(mentioned_member);
1051
1066
  if (named.length) payload.to = named;
1052
1067
  if (sequential && named.length) payload.sequential = true;
1053
1068
  if (everyone && named.length) payload.order = "auto";
@@ -1056,6 +1071,32 @@ function message_payload() {
1056
1071
  return payload;
1057
1072
  }
1058
1073
 
1074
+ /**
1075
+ * How a member may be @named: by the nickname shown on screen, and always by its member name. A nickname shared with
1076
+ * another member (or equal to another member's name) is ambiguous and is not used, so the member name stays the only way.
1077
+ */
1078
+ function mention_handles(name, names) {
1079
+ const nickname = display_name(name);
1080
+ const taken = names.filter(other => other !== name).flatMap(other => [other, display_name(other)]);
1081
+ return nickname && nickname !== name && !taken.includes(nickname) ? [nickname, name] : [name];
1082
+ }
1083
+
1084
+ /**
1085
+ * The members a text @names, longest handle first: once "@Claude评审" names one member, the shorter "@Claude" inside it
1086
+ * does not also name another (Chinese nicknames have no space after them).
1087
+ */
1088
+ function mentioned_members(text, names) {
1089
+ let prose = text.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, "").replace(/^>.*$/gm, "");
1090
+ const handles = names.flatMap(name => mention_handles(name, names).map(handle => ({ name, handle })))
1091
+ .sort((left, right) => right.handle.length - left.handle.length);
1092
+ const found = new Set();
1093
+ for (const { name, handle } of handles) {
1094
+ const pattern = new RegExp(`(^|[\\s((,,。;;::])@${handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w-])`, "gu");
1095
+ prose = prose.replace(pattern, (match, lead) => { found.add(name); return lead + " ".repeat(match.length - lead.length); });
1096
+ }
1097
+ return found;
1098
+ }
1099
+
1059
1100
  /** Whether plain text (not code or quotes) names a member with @, so a typed mention selects it. */
1060
1101
  function mentioned(text, name) {
1061
1102
  const prose = text.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, "").replace(/^>.*$/gm, "");
@@ -3833,8 +3874,7 @@ async function initialize() {
3833
3874
  const result = computer_rooms_only() && !state.remote_rooms ? { rooms: [] } : await request_json("/api/rooms");
3834
3875
  state.rooms = result.rooms;
3835
3876
  set_folder_directory(result);
3836
- const requested = new URLSearchParams(location.search).get("room");
3837
- await select_room(state.rooms.some((room) => room.id === requested) ? requested : visible_rooms()[0]?.id);
3877
+ await select_room(opening_room());
3838
3878
  set_connection(true);
3839
3879
  } catch (error) { set_connection(false); show_toast(error.message, true); }
3840
3880
  start_live_updates();
@@ -4111,7 +4151,7 @@ window.discuss_view = { snapshot: view_snapshot, write_field: write_view_field,
4111
4151
  write_expert: write_view_expert, add_instance: add_view_instance, add_preset: add_preset_instance, platform_of, execute: execute_view_action, theme: change_view_theme,
4112
4152
  admin: { register: register_admin, login: login_admin, logout: logout_admin,
4113
4153
  update_profile: update_admin_profile, avatar_from_file: admin_avatar_from_file, validate: validate_admin_form },
4114
- request: request_json, rewind_plan, identities: IDENTITIES, display_name, sources: SOURCE_LABELS,
4154
+ request: request_json, rewind_plan, identities: IDENTITIES, display_name, mention_handles, mentioned_members, sources: SOURCE_LABELS,
4115
4155
  set_member_access, set_member_model, allow_denials, set_member_adapter, reload_room_models, limits_label, stop_reason_label, participant_model, participant_files, participant_session_hint, participant_access,
4116
4156
  turn_label, turn_guidance, round_groups, limit_rounds, progress_speaker, brief_meta, max_message_length: MAX_MESSAGE_LENGTH, provenance: message_provenance, format_time };
4117
4157