form.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. export * as Form from "./form.js"
  2. import { Schema } from "effect"
  3. import { ephemeral, inventory } from "./event.js"
  4. import { ascending } from "./identifier.js"
  5. import { NonNegativeInt, optional, statics } from "./schema.js"
  6. const IDSchema = Schema.String.check(Schema.isStartsWith("frm_")).pipe(Schema.brand("Form.ID"))
  7. export const ID = IDSchema.pipe(
  8. statics((schema: typeof IDSchema) => ({ create: (id?: string) => schema.make(id ?? "frm_" + ascending()) })),
  9. )
  10. export type ID = typeof ID.Type
  11. export const Metadata = Schema.Record(Schema.String, Schema.Unknown).annotate({ identifier: "Form.Metadata" })
  12. export type Metadata = typeof Metadata.Type
  13. export const Option = Schema.Struct({
  14. value: Schema.String,
  15. label: Schema.String,
  16. description: Schema.String.pipe(optional),
  17. }).annotate({ identifier: "Form.Option" })
  18. export interface Option extends Schema.Schema.Type<typeof Option> {}
  19. // One visibility condition on a field. A field's `when` is a list of conditions that must all
  20. // hold (AND) against the current answers for the field to be active. Semantics:
  21. // - `value` must match the referenced field's type; against a multiselect answer, `eq` means
  22. // "selection includes value" and `neq` means "selection does not include value".
  23. // - An unanswered referenced field makes the condition false for both ops.
  24. // - `key` must reference a field defined earlier in the form's field list.
  25. // Inactive fields are neither required nor answerable.
  26. export const When = Schema.Struct({
  27. key: Schema.String,
  28. op: Schema.Literals(["eq", "neq"]),
  29. value: Schema.Union([Schema.String, Schema.Number, Schema.Boolean]),
  30. }).annotate({ identifier: "Form.When" })
  31. export interface When extends Schema.Schema.Type<typeof When> {}
  32. const FieldBase = {
  33. key: Schema.String,
  34. title: Schema.String.pipe(optional),
  35. description: Schema.String.pipe(optional),
  36. required: Schema.Boolean.pipe(optional),
  37. when: Schema.Array(When).pipe(optional),
  38. }
  39. export const StringField = Schema.Struct({
  40. ...FieldBase,
  41. type: Schema.Literal("string"),
  42. format: Schema.Literals(["email", "uri", "date", "date-time"]).pipe(optional),
  43. minLength: NonNegativeInt.pipe(optional),
  44. maxLength: NonNegativeInt.pipe(optional),
  45. pattern: Schema.String.pipe(optional),
  46. placeholder: Schema.String.pipe(optional),
  47. default: Schema.String.pipe(optional),
  48. options: Schema.Array(Option).pipe(optional),
  49. custom: Schema.Boolean.pipe(optional),
  50. }).annotate({ identifier: "Form.StringField" })
  51. export interface StringField extends Schema.Schema.Type<typeof StringField> {}
  52. export const NumberField = Schema.Struct({
  53. ...FieldBase,
  54. type: Schema.Literal("number"),
  55. minimum: Schema.Number.pipe(optional),
  56. maximum: Schema.Number.pipe(optional),
  57. default: Schema.Number.pipe(optional),
  58. }).annotate({ identifier: "Form.NumberField" })
  59. export interface NumberField extends Schema.Schema.Type<typeof NumberField> {}
  60. export const IntegerField = Schema.Struct({
  61. ...FieldBase,
  62. type: Schema.Literal("integer"),
  63. minimum: Schema.Number.pipe(optional),
  64. maximum: Schema.Number.pipe(optional),
  65. default: Schema.Number.pipe(optional),
  66. }).annotate({ identifier: "Form.IntegerField" })
  67. export interface IntegerField extends Schema.Schema.Type<typeof IntegerField> {}
  68. export const BooleanField = Schema.Struct({
  69. ...FieldBase,
  70. type: Schema.Literal("boolean"),
  71. default: Schema.Boolean.pipe(optional),
  72. }).annotate({ identifier: "Form.BooleanField" })
  73. export interface BooleanField extends Schema.Schema.Type<typeof BooleanField> {}
  74. export const MultiselectField = Schema.Struct({
  75. ...FieldBase,
  76. type: Schema.Literal("multiselect"),
  77. options: Schema.Array(Option),
  78. minItems: NonNegativeInt.pipe(optional),
  79. maxItems: NonNegativeInt.pipe(optional),
  80. custom: Schema.Boolean.pipe(optional),
  81. default: Schema.Array(Schema.String).pipe(optional),
  82. }).annotate({ identifier: "Form.MultiselectField" })
  83. export interface MultiselectField extends Schema.Schema.Type<typeof MultiselectField> {}
  84. export const ExternalField = Schema.Struct({
  85. key: Schema.String,
  86. type: Schema.Literal("external"),
  87. url: Schema.String,
  88. title: Schema.String.pipe(optional),
  89. description: Schema.String.pipe(optional),
  90. }).annotate({ identifier: "Form.ExternalField" })
  91. export interface ExternalField extends Schema.Schema.Type<typeof ExternalField> {}
  92. export const Field = Schema.Union([
  93. StringField,
  94. NumberField,
  95. IntegerField,
  96. BooleanField,
  97. MultiselectField,
  98. ExternalField,
  99. ]).pipe(Schema.toTaggedUnion("type"), Schema.annotate({ identifier: "Form.Field" }))
  100. export type Field = StringField | NumberField | IntegerField | BooleanField | MultiselectField | ExternalField
  101. export const Fields = Schema.NonEmptyArray(Field).annotate({ identifier: "Form.Fields" })
  102. export type Fields = typeof Fields.Type
  103. const InfoBase = {
  104. id: ID,
  105. // This should be typed as SessionID. It is a plain string only because MCP elicitation
  106. // temporarily needs the `"global"` sentinel owner, which is not a real session. Once
  107. // elicitations can be attributed to real sessions, revert this to SessionID. Do not rely
  108. // on non-session owners anywhere else.
  109. sessionID: Schema.String,
  110. title: Schema.String,
  111. metadata: Metadata.pipe(optional),
  112. }
  113. export const Info = Schema.Struct({
  114. ...InfoBase,
  115. fields: Fields,
  116. }).annotate({ identifier: "Form.Info" })
  117. export interface Info extends Schema.Schema.Type<typeof Info> {}
  118. export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate(
  119. {
  120. identifier: "Form.Value",
  121. },
  122. )
  123. export type Value = typeof Value.Type
  124. export const Answer = Schema.Record(Schema.String, Value).annotate({ identifier: "Form.Answer" })
  125. export type Answer = typeof Answer.Type
  126. export const State = Schema.Union([
  127. Schema.Struct({ status: Schema.Literal("pending") }),
  128. Schema.Struct({ status: Schema.Literal("answered"), answer: Answer }),
  129. Schema.Struct({ status: Schema.Literal("cancelled") }),
  130. ])
  131. .pipe(Schema.toTaggedUnion("status"))
  132. .annotate({ identifier: "Form.State" })
  133. export type State = typeof State.Type
  134. export const Reply = Schema.Struct({
  135. answer: Answer,
  136. }).annotate({ identifier: "Form.Reply" })
  137. export interface Reply extends Schema.Schema.Type<typeof Reply> {}
  138. const Created = ephemeral({ type: "form.created", schema: { form: Info } })
  139. const Replied = ephemeral({ type: "form.replied", schema: { id: ID, sessionID: Schema.String, answer: Answer } })
  140. const Cancelled = ephemeral({ type: "form.cancelled", schema: { id: ID, sessionID: Schema.String } })
  141. export const Event = { Created, Replied, Cancelled, Definitions: inventory(Created, Replied, Cancelled) }