share-next.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. import { Bus } from "@/bus"
  2. import { Config } from "@/config/config"
  3. import { ulid } from "ulid"
  4. import { Provider } from "@/provider/provider"
  5. import { Session } from "@/session"
  6. import { MessageV2 } from "@/session/message-v2"
  7. import { Storage } from "@/storage/storage"
  8. import { Log } from "@/util/log"
  9. import type * as SDK from "@opencode-ai/sdk/v2"
  10. export namespace ShareNext {
  11. const log = Log.create({ service: "share-next" })
  12. async function url() {
  13. return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
  14. }
  15. export async function init() {
  16. Bus.subscribe(Session.Event.Updated, async (evt) => {
  17. await sync(evt.properties.info.id, [
  18. {
  19. type: "session",
  20. data: evt.properties.info,
  21. },
  22. ])
  23. })
  24. Bus.subscribe(MessageV2.Event.Updated, async (evt) => {
  25. await sync(evt.properties.info.sessionID, [
  26. {
  27. type: "message",
  28. data: evt.properties.info,
  29. },
  30. ])
  31. if (evt.properties.info.role === "user") {
  32. await sync(evt.properties.info.sessionID, [
  33. {
  34. type: "model",
  35. data: [
  36. await Provider.getModel(evt.properties.info.model.providerID, evt.properties.info.model.modelID).then(
  37. (m) => m,
  38. ),
  39. ],
  40. },
  41. ])
  42. }
  43. })
  44. Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
  45. await sync(evt.properties.part.sessionID, [
  46. {
  47. type: "part",
  48. data: evt.properties.part,
  49. },
  50. ])
  51. })
  52. Bus.subscribe(Session.Event.Diff, async (evt) => {
  53. await sync(evt.properties.sessionID, [
  54. {
  55. type: "session_diff",
  56. data: evt.properties.diff,
  57. },
  58. ])
  59. })
  60. }
  61. export async function create(sessionID: string) {
  62. log.info("creating share", { sessionID })
  63. const result = await fetch(`${await url()}/api/share`, {
  64. method: "POST",
  65. headers: {
  66. "Content-Type": "application/json",
  67. },
  68. body: JSON.stringify({ sessionID: sessionID }),
  69. })
  70. .then((x) => x.json())
  71. .then((x) => x as { id: string; url: string; secret: string })
  72. await Storage.write(["session_share", sessionID], result)
  73. fullSync(sessionID)
  74. return result
  75. }
  76. function get(sessionID: string) {
  77. return Storage.read<{
  78. id: string
  79. secret: string
  80. url: string
  81. }>(["session_share", sessionID])
  82. }
  83. type Data =
  84. | {
  85. type: "session"
  86. data: SDK.Session
  87. }
  88. | {
  89. type: "message"
  90. data: SDK.Message
  91. }
  92. | {
  93. type: "part"
  94. data: SDK.Part
  95. }
  96. | {
  97. type: "session_diff"
  98. data: SDK.FileDiff[]
  99. }
  100. | {
  101. type: "model"
  102. data: SDK.Model[]
  103. }
  104. const queue = new Map<string, { timeout: NodeJS.Timeout; data: Map<string, Data> }>()
  105. async function sync(sessionID: string, data: Data[]) {
  106. const existing = queue.get(sessionID)
  107. if (existing) {
  108. for (const item of data) {
  109. existing.data.set("id" in item ? (item.id as string) : ulid(), item)
  110. }
  111. return
  112. }
  113. const dataMap = new Map<string, Data>()
  114. for (const item of data) {
  115. dataMap.set("id" in item ? (item.id as string) : ulid(), item)
  116. }
  117. const timeout = setTimeout(async () => {
  118. const queued = queue.get(sessionID)
  119. if (!queued) return
  120. queue.delete(sessionID)
  121. const share = await get(sessionID)
  122. if (!share) return
  123. await fetch(`${await url()}/api/share/${share.id}/sync`, {
  124. method: "POST",
  125. headers: {
  126. "Content-Type": "application/json",
  127. },
  128. body: JSON.stringify({
  129. secret: share.secret,
  130. data: Array.from(queued.data.values()),
  131. }),
  132. })
  133. }, 1000)
  134. queue.set(sessionID, { timeout, data: dataMap })
  135. }
  136. export async function remove(sessionID: string) {
  137. log.info("removing share", { sessionID })
  138. const share = await get(sessionID)
  139. if (!share) return
  140. await fetch(`${await url()}/api/share/${share.id}`, {
  141. method: "DELETE",
  142. headers: {
  143. "Content-Type": "application/json",
  144. },
  145. body: JSON.stringify({
  146. secret: share.secret,
  147. }),
  148. })
  149. await Storage.remove(["session_share", sessionID])
  150. }
  151. async function fullSync(sessionID: string) {
  152. log.info("full sync", { sessionID })
  153. const session = await Session.get(sessionID)
  154. const diffs = await Session.diff(sessionID)
  155. const messages = await Array.fromAsync(MessageV2.stream(sessionID))
  156. const models = await Promise.all(
  157. messages
  158. .filter((m) => m.info.role === "user")
  159. .map((m) => (m.info as SDK.UserMessage).model)
  160. .map((m) => Provider.getModel(m.providerID, m.modelID).then((m) => m)),
  161. )
  162. await sync(sessionID, [
  163. {
  164. type: "session",
  165. data: session,
  166. },
  167. ...messages.map((x) => ({
  168. type: "message" as const,
  169. data: x.info,
  170. })),
  171. ...messages.flatMap((x) => x.parts.map((y) => ({ type: "part" as const, data: y }))),
  172. {
  173. type: "session_diff",
  174. data: diffs,
  175. },
  176. {
  177. type: "model",
  178. data: models,
  179. },
  180. ])
  181. }
  182. }