@open-mercato/ai-assistant 0.6.7 → 0.6.8-develop.6874.1.982d6097d8

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.
Files changed (120) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +12 -29
  3. package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.js +80 -0
  4. package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.js.map +7 -0
  5. package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.js +124 -0
  6. package/dist/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.js.map +7 -0
  7. package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js +30 -0
  8. package/dist/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.js.map +2 -2
  9. package/dist/modules/ai_assistant/api/ai/chat/route.js +7 -0
  10. package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
  11. package/dist/modules/ai_assistant/api/moderation-flags/route.js +121 -0
  12. package/dist/modules/ai_assistant/api/moderation-flags/route.js.map +7 -0
  13. package/dist/modules/ai_assistant/api/settings/allowlist/route.js +10 -10
  14. package/dist/modules/ai_assistant/api/settings/allowlist/route.js.map +2 -2
  15. package/dist/modules/ai_assistant/api/settings/route.js +34 -13
  16. package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
  17. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +121 -15
  18. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
  19. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +2 -2
  20. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +2 -2
  21. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js +139 -0
  22. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.js.map +7 -0
  23. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js +10 -0
  24. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.js.map +7 -0
  25. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js +26 -0
  26. package/dist/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.js.map +7 -0
  27. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +4 -4
  28. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
  29. package/dist/modules/ai_assistant/data/entities.js +42 -0
  30. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  31. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +3 -1
  32. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +2 -2
  33. package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js +44 -0
  34. package/dist/modules/ai_assistant/data/repositories/AiModerationFlagRepository.js.map +7 -0
  35. package/dist/modules/ai_assistant/di.js +6 -2
  36. package/dist/modules/ai_assistant/di.js.map +2 -2
  37. package/dist/modules/ai_assistant/events.js +6 -0
  38. package/dist/modules/ai_assistant/events.js.map +2 -2
  39. package/dist/modules/ai_assistant/i18n/de.json +42 -0
  40. package/dist/modules/ai_assistant/i18n/en.json +42 -0
  41. package/dist/modules/ai_assistant/i18n/es.json +42 -0
  42. package/dist/modules/ai_assistant/i18n/ko.json +527 -0
  43. package/dist/modules/ai_assistant/i18n/pl.json +42 -0
  44. package/dist/modules/ai_assistant/lib/agent-runtime.js +158 -5
  45. package/dist/modules/ai_assistant/lib/agent-runtime.js.map +2 -2
  46. package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
  47. package/dist/modules/ai_assistant/lib/auth.js +6 -7
  48. package/dist/modules/ai_assistant/lib/auth.js.map +2 -2
  49. package/dist/modules/ai_assistant/lib/entity-graph.js +3 -3
  50. package/dist/modules/ai_assistant/lib/entity-graph.js.map +2 -2
  51. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js +3 -0
  52. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +2 -2
  53. package/dist/modules/ai_assistant/lib/llm-adapters/openai.js +8 -1
  54. package/dist/modules/ai_assistant/lib/llm-adapters/openai.js.map +2 -2
  55. package/dist/modules/ai_assistant/lib/model-factory.js +6 -1
  56. package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
  57. package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js +43 -0
  58. package/dist/modules/ai_assistant/lib/moderation-flag-recorder.js.map +7 -0
  59. package/dist/modules/ai_assistant/lib/moderation-policy.js +24 -0
  60. package/dist/modules/ai_assistant/lib/moderation-policy.js.map +7 -0
  61. package/dist/modules/ai_assistant/lib/moderation.js +109 -0
  62. package/dist/modules/ai_assistant/lib/moderation.js.map +7 -0
  63. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js +2 -0
  64. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +2 -2
  65. package/dist/modules/ai_assistant/lib/tool-search.js +4 -12
  66. package/dist/modules/ai_assistant/lib/tool-search.js.map +2 -2
  67. package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js +19 -0
  68. package/dist/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.js.map +7 -0
  69. package/generated/entities/ai_agent_runtime_override/index.ts +1 -0
  70. package/generated/entities/ai_moderation_flag/index.ts +9 -0
  71. package/generated/entities.ids.generated.ts +2 -1
  72. package/generated/entity-fields-registry.ts +13 -1
  73. package/package.json +13 -12
  74. package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-008-settings-and-audit.spec.ts +126 -0
  75. package/src/modules/ai_assistant/__integration__/TC-AI-MODERATION-009-ui.spec.ts +161 -0
  76. package/src/modules/ai_assistant/__integration__/helpers/aiAssistantFixtures.ts +48 -0
  77. package/src/modules/ai_assistant/api/ai/chat/route.ts +10 -0
  78. package/src/modules/ai_assistant/api/moderation-flags/__tests__/route.test.ts +122 -0
  79. package/src/modules/ai_assistant/api/moderation-flags/route.ts +137 -0
  80. package/src/modules/ai_assistant/api/settings/__tests__/route.test.ts +58 -8
  81. package/src/modules/ai_assistant/api/settings/allowlist/route.ts +10 -12
  82. package/src/modules/ai_assistant/api/settings/route.ts +33 -10
  83. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +161 -15
  84. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +2 -2
  85. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/AiModerationFlagsPageClient.tsx +186 -0
  86. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.meta.ts +24 -0
  87. package/src/modules/ai_assistant/backend/config/ai-assistant/moderation-flags/page.tsx +12 -0
  88. package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +4 -4
  89. package/src/modules/ai_assistant/data/entities.ts +56 -0
  90. package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +4 -0
  91. package/src/modules/ai_assistant/data/repositories/AiModerationFlagRepository.ts +79 -0
  92. package/src/modules/ai_assistant/di.ts +5 -1
  93. package/src/modules/ai_assistant/events.ts +21 -0
  94. package/src/modules/ai_assistant/i18n/de.json +42 -0
  95. package/src/modules/ai_assistant/i18n/en.json +42 -0
  96. package/src/modules/ai_assistant/i18n/es.json +42 -0
  97. package/src/modules/ai_assistant/i18n/ko.json +527 -0
  98. package/src/modules/ai_assistant/i18n/pl.json +42 -0
  99. package/src/modules/ai_assistant/lib/__tests__/auth.test.ts +2 -5
  100. package/src/modules/ai_assistant/lib/__tests__/input-moderation-gate.test.ts +121 -0
  101. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-anthropic.test.ts +13 -0
  102. package/src/modules/ai_assistant/lib/__tests__/llm-adapters-openai.test.ts +41 -0
  103. package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +60 -0
  104. package/src/modules/ai_assistant/lib/__tests__/moderation-flag-recorder.test.ts +76 -0
  105. package/src/modules/ai_assistant/lib/__tests__/moderation-policy.test.ts +86 -0
  106. package/src/modules/ai_assistant/lib/__tests__/moderation.test.ts +114 -0
  107. package/src/modules/ai_assistant/lib/agent-runtime.ts +259 -0
  108. package/src/modules/ai_assistant/lib/ai-agent-definition.ts +10 -0
  109. package/src/modules/ai_assistant/lib/auth.ts +6 -15
  110. package/src/modules/ai_assistant/lib/entity-graph.ts +3 -3
  111. package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +10 -0
  112. package/src/modules/ai_assistant/lib/llm-adapters/openai.ts +32 -1
  113. package/src/modules/ai_assistant/lib/model-factory.ts +33 -0
  114. package/src/modules/ai_assistant/lib/moderation-flag-recorder.ts +75 -0
  115. package/src/modules/ai_assistant/lib/moderation-policy.ts +77 -0
  116. package/src/modules/ai_assistant/lib/moderation.ts +179 -0
  117. package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +2 -0
  118. package/src/modules/ai_assistant/lib/tool-search.ts +4 -17
  119. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +388 -16
  120. package/src/modules/ai_assistant/migrations/Migration20260610134045_ai_assistant.ts +21 -0
@@ -121,6 +121,14 @@ type AgentResolution = {
121
121
  modelId: string
122
122
  baseURL: string | null
123
123
  source: string
124
+ moderation?: {
125
+ /** `untrustedInput` agents enforce moderation (non-editable). */
126
+ enforced: boolean
127
+ /** Per-agent control value: null = inherit, true = on, false = off. */
128
+ override: boolean | null
129
+ /** Resolved runtime policy after the precedence chain. */
130
+ effective: 'enforced' | 'on' | 'off'
131
+ }
124
132
  }
125
133
 
126
134
  type TenantAllowlist = {
@@ -706,7 +714,7 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
706
714
  </div>
707
715
 
708
716
  <Alert
709
- variant="info"
717
+ status="information"
710
718
  icon={<ShieldAlert aria-hidden="true" />}
711
719
  data-ai-agent-mutation-policy-notice
712
720
  >
@@ -732,7 +740,7 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
732
740
  )}
733
741
  />
734
742
  ) : query.isError ? (
735
- <Alert variant="destructive" data-ai-agent-mutation-policy-load-error>
743
+ <Alert status="error" data-ai-agent-mutation-policy-load-error>
736
744
  <AlertTitle>
737
745
  {t(
738
746
  'ai_assistant.agents.mutation_policy.loadErrorTitle',
@@ -820,7 +828,7 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
820
828
  )}
821
829
 
822
830
  {state.kind === 'success' ? (
823
- <Alert variant="success" data-ai-agent-mutation-policy-state="success">
831
+ <Alert status="success" data-ai-agent-mutation-policy-state="success">
824
832
  <AlertTitle>
825
833
  {t('ai_assistant.agents.mutation_policy.savedTitle', 'Mutation policy updated')}
826
834
  </AlertTitle>
@@ -828,7 +836,7 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
828
836
  </Alert>
829
837
  ) : null}
830
838
  {state.kind === 'error' ? (
831
- <Alert variant="destructive" data-ai-agent-mutation-policy-state="error">
839
+ <Alert status="error" data-ai-agent-mutation-policy-state="error">
832
840
  <AlertTitle>
833
841
  {t(
834
842
  'ai_assistant.agents.mutation_policy.errorTitle',
@@ -875,6 +883,142 @@ function MutationPolicySection({ agent }: { agent: AgentSettings }) {
875
883
  )
876
884
  }
877
885
 
886
+ type ModerationControlValue = 'inherit' | 'on' | 'off'
887
+
888
+ function ModerationSection({ agent }: { agent: AgentSettings }) {
889
+ const t = useT()
890
+ const queryClient = useQueryClient()
891
+ const settingsQuery = useQuery<RuntimeSettingsResponse>({
892
+ queryKey: ['ai_assistant', 'agent_settings', 'runtime_settings'],
893
+ queryFn: fetchRuntimeSettings,
894
+ retry: false,
895
+ })
896
+ const agentResolution = settingsQuery.data?.agents.find((entry) => entry.agentId === agent.id) ?? null
897
+ const moderation = agentResolution?.moderation ?? null
898
+ const enforced = moderation?.enforced === true
899
+ const currentValue: ModerationControlValue =
900
+ moderation?.override === true ? 'on' : moderation?.override === false ? 'off' : 'inherit'
901
+ const [isSaving, setIsSaving] = React.useState(false)
902
+ const { runMutation } = useGuardedMutation({ contextId: `ai-agent-moderation-${agent.id}` })
903
+
904
+ const save = React.useCallback(
905
+ async (value: ModerationControlValue) => {
906
+ if (isSaving || enforced) return
907
+ setIsSaving(true)
908
+ try {
909
+ const inputModeration = value === 'inherit' ? null : value === 'on'
910
+ await runMutation({
911
+ operation: async () => {
912
+ const { ok, status, result } = await apiCall<{ error?: string }>(
913
+ '/api/ai_assistant/settings',
914
+ {
915
+ method: 'PUT',
916
+ headers: { 'content-type': 'application/json' },
917
+ credentials: 'include',
918
+ body: JSON.stringify({ agentId: agent.id, inputModeration }),
919
+ },
920
+ )
921
+ if (!ok) {
922
+ throw new Error(result?.error ?? `[internal] Failed to save moderation setting (${status}).`)
923
+ }
924
+ },
925
+ context: {},
926
+ })
927
+ flash(t('ai_assistant.agents.moderation.saved', 'Input moderation setting saved.'), 'success')
928
+ await queryClient.invalidateQueries({
929
+ queryKey: ['ai_assistant', 'agent_settings', 'runtime_settings'],
930
+ })
931
+ } catch (err) {
932
+ flash(err instanceof Error ? err.message : String(err), 'error')
933
+ } finally {
934
+ setIsSaving(false)
935
+ }
936
+ },
937
+ [agent.id, enforced, isSaving, queryClient, runMutation, t],
938
+ )
939
+
940
+ return (
941
+ <section
942
+ className="rounded-lg border border-border bg-background p-4"
943
+ data-ai-agent-moderation={agent.id}
944
+ >
945
+ <header className="flex flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
946
+ <div className="flex min-w-0 items-start gap-2">
947
+ <ShieldAlert className="size-4 text-muted-foreground" aria-hidden />
948
+ <div className="min-w-0">
949
+ <h3 className="text-sm font-semibold">
950
+ {t('ai_assistant.agents.moderation.title', 'Input moderation')}
951
+ </h3>
952
+ <p className="text-xs text-muted-foreground">
953
+ {t(
954
+ 'ai_assistant.agents.moderation.subtitle',
955
+ 'Screen user input for unsafe content before the model runs. Applies only when the resolved provider supports moderation.',
956
+ )}
957
+ </p>
958
+ </div>
959
+ </div>
960
+ {moderation ? (
961
+ <StatusBadge
962
+ variant={moderation.effective === 'off' ? 'neutral' : 'info'}
963
+ dot
964
+ data-ai-agent-moderation-effective
965
+ >
966
+ {moderation.effective === 'enforced'
967
+ ? t('ai_assistant.agents.moderation.effectiveEnforced', 'Enforced')
968
+ : moderation.effective === 'on'
969
+ ? t('ai_assistant.agents.moderation.effectiveOn', 'On')
970
+ : t('ai_assistant.agents.moderation.effectiveOff', 'Off')}
971
+ </StatusBadge>
972
+ ) : null}
973
+ </header>
974
+
975
+ <div className="mt-3">
976
+ {enforced ? (
977
+ <div className="flex flex-wrap items-center gap-2" data-ai-agent-moderation-enforced>
978
+ <StatusBadge variant="warning" dot>
979
+ {t('ai_assistant.agents.moderation.enforcedBadge', 'Enforced')}
980
+ </StatusBadge>
981
+ <p className="text-xs text-muted-foreground">
982
+ {t(
983
+ 'ai_assistant.agents.moderation.enforcedHint',
984
+ 'This agent accepts untrusted input, so moderation is always on and cannot be disabled here.',
985
+ )}
986
+ </p>
987
+ </div>
988
+ ) : (
989
+ <div className="max-w-xs">
990
+ <span className="text-overline font-semibold uppercase tracking-wider text-muted-foreground">
991
+ {t('ai_assistant.agents.moderation.controlLabel', 'Moderation')}
992
+ </span>
993
+ <div className="mt-1">
994
+ <Select
995
+ value={currentValue}
996
+ onValueChange={(value) => save(value as ModerationControlValue)}
997
+ disabled={isSaving || !agentResolution}
998
+ >
999
+ <SelectTrigger aria-label={t('ai_assistant.agents.moderation.controlLabel', 'Moderation')}>
1000
+ <SelectValue />
1001
+ </SelectTrigger>
1002
+ <SelectContent>
1003
+ <SelectItem value="inherit">
1004
+ {t('ai_assistant.agents.moderation.inheritLabel', 'Inherit')}
1005
+ </SelectItem>
1006
+ <SelectItem value="on">
1007
+ {t('ai_assistant.agents.moderation.onLabel', 'On')}
1008
+ </SelectItem>
1009
+ <SelectItem value="off">
1010
+ {t('ai_assistant.agents.moderation.offLabel', 'Off')}
1011
+ </SelectItem>
1012
+ </SelectContent>
1013
+ </Select>
1014
+ </div>
1015
+ </div>
1016
+ )}
1017
+ </div>
1018
+ </section>
1019
+ )
1020
+ }
1021
+
878
1022
  function AgentModelOverrideSection({ agent }: { agent: AgentSettings }) {
879
1023
  const t = useT()
880
1024
  const queryClient = useQueryClient()
@@ -1190,7 +1334,7 @@ function AgentModelOverrideSection({ agent }: { agent: AgentSettings }) {
1190
1334
  )}
1191
1335
  />
1192
1336
  ) : settingsQuery.isError ? (
1193
- <Alert variant="destructive" data-ai-agent-model-override-load-error>
1337
+ <Alert status="error" data-ai-agent-model-override-load-error>
1194
1338
  <AlertTitle>
1195
1339
  {t(
1196
1340
  'ai_assistant.agents.model_override.loadErrorTitle',
@@ -1400,12 +1544,12 @@ function AgentModelOverrideSection({ agent }: { agent: AgentSettings }) {
1400
1544
  ) : null}
1401
1545
 
1402
1546
  {state.kind === 'success' ? (
1403
- <Alert variant="success" data-ai-agent-model-override-state="success">
1547
+ <Alert status="success" data-ai-agent-model-override-state="success">
1404
1548
  <AlertDescription>{state.message}</AlertDescription>
1405
1549
  </Alert>
1406
1550
  ) : null}
1407
1551
  {state.kind === 'error' ? (
1408
- <Alert variant="destructive" data-ai-agent-model-override-state="error">
1552
+ <Alert status="error" data-ai-agent-model-override-state="error">
1409
1553
  <AlertDescription>{state.message}</AlertDescription>
1410
1554
  </Alert>
1411
1555
  ) : null}
@@ -1649,7 +1793,7 @@ function LoopPolicySection({ agent }: { agent: AgentSettings }) {
1649
1793
  message={t('ai_assistant.agents.loop_policy.loading', 'Loading loop policy...')}
1650
1794
  />
1651
1795
  ) : query.isError ? (
1652
- <Alert variant="destructive" data-ai-agent-loop-policy-load-error>
1796
+ <Alert status="error" data-ai-agent-loop-policy-load-error>
1653
1797
  <AlertTitle>
1654
1798
  {t('ai_assistant.agents.loop_policy.loadErrorTitle', 'Failed to load loop policy')}
1655
1799
  </AlertTitle>
@@ -1747,7 +1891,7 @@ function LoopPolicySection({ agent }: { agent: AgentSettings }) {
1747
1891
  </div>
1748
1892
 
1749
1893
  {state.kind === 'success' ? (
1750
- <Alert variant="success" data-ai-agent-loop-policy-state="success">
1894
+ <Alert status="success" data-ai-agent-loop-policy-state="success">
1751
1895
  <AlertTitle>
1752
1896
  {t('ai_assistant.agents.loop_policy.savedTitle', 'Loop policy updated')}
1753
1897
  </AlertTitle>
@@ -1755,7 +1899,7 @@ function LoopPolicySection({ agent }: { agent: AgentSettings }) {
1755
1899
  </Alert>
1756
1900
  ) : null}
1757
1901
  {state.kind === 'error' ? (
1758
- <Alert variant="destructive" data-ai-agent-loop-policy-state="error">
1902
+ <Alert status="error" data-ai-agent-loop-policy-state="error">
1759
1903
  <AlertTitle>
1760
1904
  {t(
1761
1905
  'ai_assistant.agents.loop_policy.errorTitle',
@@ -2063,6 +2207,8 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
2063
2207
 
2064
2208
  <AgentModelOverrideSection agent={agent} />
2065
2209
 
2210
+ <ModerationSection agent={agent} />
2211
+
2066
2212
  <MutationPolicySection agent={agent} />
2067
2213
 
2068
2214
  <LoopPolicySection agent={agent} />
@@ -2100,7 +2246,7 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
2100
2246
  </header>
2101
2247
  <div className="mt-3">
2102
2248
  <Alert
2103
- variant="info"
2249
+ status="information"
2104
2250
  icon={<Wand2 aria-hidden="true" />}
2105
2251
  data-ai-agent-prompt-notice
2106
2252
  >
@@ -2116,7 +2262,7 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
2116
2262
  </Alert>
2117
2263
  </div>
2118
2264
  {saveState.kind === 'success' ? (
2119
- <Alert variant="success" className="mt-3" data-ai-agent-prompt-state="success">
2265
+ <Alert status="success" className="mt-3" data-ai-agent-prompt-state="success">
2120
2266
  <AlertTitle>
2121
2267
  {saveState.version > 0
2122
2268
  ? t('ai_assistant.agents.override.savedTitle', 'Prompt override saved')
@@ -2130,7 +2276,7 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
2130
2276
  </Alert>
2131
2277
  ) : null}
2132
2278
  {saveState.kind === 'error' ? (
2133
- <Alert variant="destructive" className="mt-3" data-ai-agent-prompt-state="error">
2279
+ <Alert status="error" className="mt-3" data-ai-agent-prompt-state="error">
2134
2280
  <AlertTitle>
2135
2281
  {t('ai_assistant.agents.override.errorTitle', 'Failed to save prompt override')}
2136
2282
  </AlertTitle>
@@ -2250,7 +2396,7 @@ function AgentDetailPanel({ agent }: { agent: AgentSettings }) {
2250
2396
  )}
2251
2397
  />
2252
2398
  ) : overrideQuery.isError ? (
2253
- <Alert variant="destructive" data-ai-agent-override-history-error>
2399
+ <Alert status="error" data-ai-agent-override-history-error>
2254
2400
  <AlertTitle>
2255
2401
  {t(
2256
2402
  'ai_assistant.agents.override.history.errorTitle',
@@ -2359,7 +2505,7 @@ export function AiAgentSettingsPageClient() {
2359
2505
 
2360
2506
  if (isError) {
2361
2507
  return (
2362
- <Alert variant="destructive" data-ai-agent-settings-error>
2508
+ <Alert status="error" data-ai-agent-settings-error>
2363
2509
  <AlertTitle>
2364
2510
  {t('ai_assistant.agents.loadErrorTitle', 'Failed to load AI agents')}
2365
2511
  </AlertTitle>
@@ -129,7 +129,7 @@ export function AiTenantAllowlistPageClient(): React.JSX.Element {
129
129
  return (
130
130
  <div className="flex max-w-3xl flex-col gap-4">
131
131
  {pageHeader}
132
- <Alert variant="destructive">
132
+ <Alert status="error">
133
133
  <AlertTitle>{t('ai_assistant.allowlist.loadError.title', 'Failed to load allowlist')}</AlertTitle>
134
134
  <AlertDescription>
135
135
  {settingsQuery.error instanceof Error
@@ -311,7 +311,7 @@ export function AiTenantAllowlistPageClient(): React.JSX.Element {
311
311
  {envBanner}
312
312
 
313
313
  {feedback ? (
314
- <Alert variant={feedback.kind === 'error' ? 'destructive' : undefined}>
314
+ <Alert status={feedback.kind === 'error' ? 'error' : undefined}>
315
315
  <AlertDescription>{feedback.text}</AlertDescription>
316
316
  </Alert>
317
317
  ) : null}
@@ -0,0 +1,186 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useQuery } from '@tanstack/react-query'
5
+ import type { ColumnDef } from '@tanstack/react-table'
6
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import { DataTable } from '@open-mercato/ui/backend/DataTable'
8
+ import { Button } from '@open-mercato/ui/primitives/button'
9
+ import { Input } from '@open-mercato/ui/primitives/input'
10
+ import { Label } from '@open-mercato/ui/primitives/label'
11
+ import { StatusBadge } from '@open-mercato/ui/primitives/status-badge'
12
+ import { apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
13
+
14
+ const PAGE_SIZE = 50
15
+
16
+ type ModerationCategory = { flagged: boolean; score: number }
17
+
18
+ type ModerationFlagRow = {
19
+ id: string
20
+ tenantId: string
21
+ organizationId: string | null
22
+ agentId: string
23
+ userId: string
24
+ providerId: string
25
+ modelId: string
26
+ categories: Record<string, ModerationCategory>
27
+ createdAt: string
28
+ }
29
+
30
+ type ModerationFlagsResponse = {
31
+ items: ModerationFlagRow[]
32
+ total: number
33
+ page: number
34
+ pageSize: number
35
+ }
36
+
37
+ async function fetchModerationFlags(params: {
38
+ page: number
39
+ from: string
40
+ to: string
41
+ agentId: string
42
+ userId: string
43
+ }): Promise<ModerationFlagsResponse> {
44
+ const search = new URLSearchParams()
45
+ search.set('page', String(params.page))
46
+ search.set('pageSize', String(PAGE_SIZE))
47
+ if (params.from) search.set('from', params.from)
48
+ if (params.to) search.set('to', params.to)
49
+ if (params.agentId) search.set('agentId', params.agentId)
50
+ if (params.userId) search.set('userId', params.userId)
51
+ const { result, status } = await apiCallOrThrow<ModerationFlagsResponse>(
52
+ `/api/ai_assistant/moderation-flags?${search.toString()}`,
53
+ undefined,
54
+ { errorMessage: 'Failed to load moderation flags' },
55
+ )
56
+ if (!result) throw new Error(`Failed to load moderation flags (${status})`)
57
+ return result
58
+ }
59
+
60
+ function flaggedCategoryNames(categories: Record<string, ModerationCategory>): string[] {
61
+ return Object.entries(categories)
62
+ .filter(([, value]) => value.flagged)
63
+ .map(([name]) => name)
64
+ }
65
+
66
+ export function AiModerationFlagsPageClient() {
67
+ const t = useT()
68
+ const [page, setPage] = React.useState(1)
69
+ const [from, setFrom] = React.useState('')
70
+ const [to, setTo] = React.useState('')
71
+ const [agentId, setAgentId] = React.useState('')
72
+ const [userId, setUserId] = React.useState('')
73
+ const [applied, setApplied] = React.useState({ from: '', to: '', agentId: '', userId: '' })
74
+
75
+ const query = useQuery<ModerationFlagsResponse>({
76
+ queryKey: ['ai_assistant', 'moderation_flags', page, applied],
77
+ queryFn: () => fetchModerationFlags({ page, ...applied }),
78
+ retry: false,
79
+ })
80
+
81
+ const applyFilters = React.useCallback(() => {
82
+ setPage(1)
83
+ setApplied({ from, to, agentId, userId })
84
+ }, [from, to, agentId, userId])
85
+
86
+ const columns = React.useMemo<ColumnDef<ModerationFlagRow, unknown>[]>(
87
+ () => [
88
+ {
89
+ accessorKey: 'agentId',
90
+ header: t('ai_assistant.moderationFlags.columns.agent', 'Agent'),
91
+ },
92
+ {
93
+ accessorKey: 'userId',
94
+ header: t('ai_assistant.moderationFlags.columns.user', 'User'),
95
+ meta: { truncate: true, maxWidth: 220 },
96
+ },
97
+ {
98
+ accessorKey: 'categories',
99
+ header: t('ai_assistant.moderationFlags.columns.categories', 'Categories'),
100
+ cell: ({ row }) => {
101
+ const names = flaggedCategoryNames(row.original.categories)
102
+ if (names.length === 0) {
103
+ return <span className="text-muted-foreground">—</span>
104
+ }
105
+ return (
106
+ <div className="flex flex-wrap gap-1">
107
+ {names.map((name) => (
108
+ <StatusBadge key={name} variant="error" dot>
109
+ {name}
110
+ </StatusBadge>
111
+ ))}
112
+ </div>
113
+ )
114
+ },
115
+ },
116
+ {
117
+ accessorKey: 'createdAt',
118
+ header: t('ai_assistant.moderationFlags.columns.createdAt', 'Flagged at'),
119
+ cell: ({ row }) => new Date(row.original.createdAt).toLocaleString(),
120
+ },
121
+ ],
122
+ [t],
123
+ )
124
+
125
+ const total = query.data?.total ?? 0
126
+
127
+ return (
128
+ <div className="flex flex-col gap-4">
129
+ <div>
130
+ <h2 className="text-base font-semibold">
131
+ {t('ai_assistant.moderationFlags.title', 'Moderation flags')}
132
+ </h2>
133
+ <p className="text-xs text-muted-foreground">
134
+ {t(
135
+ 'ai_assistant.moderationFlags.subtitle',
136
+ 'Inputs blocked by the content safety filter. Category flags and scores only — no prompt content is stored.',
137
+ )}
138
+ </p>
139
+ </div>
140
+
141
+ <div className="flex flex-wrap items-end gap-3">
142
+ <div className="flex flex-col gap-1">
143
+ <Label htmlFor="moderation-from">{t('ai_assistant.moderationFlags.filters.from', 'From')}</Label>
144
+ <Input id="moderation-from" type="date" value={from} onChange={(e) => setFrom(e.target.value)} />
145
+ </div>
146
+ <div className="flex flex-col gap-1">
147
+ <Label htmlFor="moderation-to">{t('ai_assistant.moderationFlags.filters.to', 'To')}</Label>
148
+ <Input id="moderation-to" type="date" value={to} onChange={(e) => setTo(e.target.value)} />
149
+ </div>
150
+ <div className="flex flex-col gap-1">
151
+ <Label htmlFor="moderation-agent">{t('ai_assistant.moderationFlags.columns.agent', 'Agent')}</Label>
152
+ <Input
153
+ id="moderation-agent"
154
+ value={agentId}
155
+ onChange={(e) => setAgentId(e.target.value)}
156
+ placeholder={t('ai_assistant.moderationFlags.filters.agentPlaceholder', 'module.agent')}
157
+ />
158
+ </div>
159
+ <div className="flex flex-col gap-1">
160
+ <Label htmlFor="moderation-user">{t('ai_assistant.moderationFlags.columns.user', 'User')}</Label>
161
+ <Input id="moderation-user" value={userId} onChange={(e) => setUserId(e.target.value)} />
162
+ </div>
163
+ <Button type="button" variant="outline" onClick={applyFilters}>
164
+ {t('ai_assistant.moderationFlags.filters.apply', 'Apply')}
165
+ </Button>
166
+ </div>
167
+
168
+ <DataTable<ModerationFlagRow>
169
+ columns={columns}
170
+ data={query.data?.items ?? []}
171
+ isLoading={query.isLoading}
172
+ error={query.error ? (query.error as Error).message : undefined}
173
+ emptyState={t('ai_assistant.moderationFlags.empty', 'No flagged messages.')}
174
+ pagination={{
175
+ page,
176
+ pageSize: PAGE_SIZE,
177
+ total,
178
+ totalPages: Math.max(1, Math.ceil(total / PAGE_SIZE)),
179
+ onPageChange: setPage,
180
+ }}
181
+ />
182
+ </div>
183
+ )
184
+ }
185
+
186
+ export default AiModerationFlagsPageClient
@@ -0,0 +1,24 @@
1
+ import React from 'react'
2
+
3
+ const moderationIcon = React.createElement(
4
+ 'svg',
5
+ { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
6
+ React.createElement('path', { d: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }),
7
+ React.createElement('path', { d: 'M12 8v4' }),
8
+ React.createElement('path', { d: 'M12 16h.01' }),
9
+ )
10
+
11
+ export const metadata = {
12
+ requireAuth: true,
13
+ requireFeatures: ['ai_assistant.settings.manage'],
14
+ pageTitle: 'Moderation flags',
15
+ pageTitleKey: 'ai_assistant.moderationFlags.navTitle',
16
+ pageGroup: 'Module Configs',
17
+ pageGroupKey: 'settings.sections.moduleConfigs',
18
+ pageOrder: 432,
19
+ icon: moderationIcon,
20
+ pageContext: 'settings' as const,
21
+ breadcrumb: [
22
+ { label: 'Moderation flags', labelKey: 'ai_assistant.moderationFlags.navTitle' },
23
+ ],
24
+ } as const
@@ -0,0 +1,12 @@
1
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
2
+ import { AiModerationFlagsPageClient } from './AiModerationFlagsPageClient'
3
+
4
+ export default async function AiModerationFlagsPage() {
5
+ return (
6
+ <Page>
7
+ <PageBody>
8
+ <AiModerationFlagsPageClient />
9
+ </PageBody>
10
+ </Page>
11
+ )
12
+ }
@@ -318,7 +318,7 @@ function ChatLane({ agent, debug }: { agent: PlaygroundAgent; debug: boolean })
318
318
 
319
319
  if (agent.executionMode !== 'chat') {
320
320
  return (
321
- <Alert variant="info" data-ai-playground-unsupported="chat">
321
+ <Alert status="information" data-ai-playground-unsupported="chat">
322
322
  <AlertTitle>
323
323
  {t(
324
324
  'ai_assistant.playground.chat.notSupportedTitle',
@@ -413,7 +413,7 @@ function ObjectLane({ agent }: { agent: PlaygroundAgent }) {
413
413
 
414
414
  if (!isSupported) {
415
415
  return (
416
- <Alert variant="info" data-ai-playground-unsupported="object">
416
+ <Alert status="information" data-ai-playground-unsupported="object">
417
417
  <AlertTitle>
418
418
  {t(
419
419
  'ai_assistant.playground.object.notSupportedTitle',
@@ -474,7 +474,7 @@ function ObjectLane({ agent }: { agent: PlaygroundAgent }) {
474
474
  </div>
475
475
 
476
476
  {error ? (
477
- <Alert variant="destructive" data-ai-playground-object-error={error.code ?? 'unknown'}>
477
+ <Alert status="error" data-ai-playground-object-error={error.code ?? 'unknown'}>
478
478
  <AlertTitle>
479
479
  {t('ai_assistant.playground.object.errorTitle', 'Object run failed')}
480
480
  </AlertTitle>
@@ -582,7 +582,7 @@ export function AiPlaygroundPageClient() {
582
582
 
583
583
  if (isError) {
584
584
  return (
585
- <Alert variant="destructive" data-ai-playground-error>
585
+ <Alert status="error" data-ai-playground-error>
586
586
  <AlertTitle>
587
587
  {t('ai_assistant.playground.loadErrorTitle', 'Failed to load AI agents')}
588
588
  </AlertTitle>
@@ -361,6 +361,17 @@ export class AiAgentRuntimeOverride {
361
361
  */
362
362
  @Property({ name: 'loop_active_tools_json', type: 'jsonb', nullable: true })
363
363
  loopActiveToolsJson?: unknown | null
364
+
365
+ /**
366
+ * Per-agent (or tenant-wide when `agent_id` is null) input-moderation
367
+ * override. `true` = on, `false` = off, `null` = inherit (env default →
368
+ * off). Additive nullable column; existing rows inherit. The `untrustedInput`
369
+ * agent flag still forces moderation regardless of this value.
370
+ *
371
+ * Spec `2026-06-04-ai-input-moderation-and-safety-identifiers`.
372
+ */
373
+ @Property({ name: 'input_moderation', type: 'boolean', nullable: true })
374
+ inputModeration?: boolean | null
364
375
  }
365
376
 
366
377
  /**
@@ -933,3 +944,48 @@ export class AiChatMessage {
933
944
  @Property({ name: 'deleted_at', type: Date, nullable: true })
934
945
  deletedAt?: Date | null
935
946
  }
947
+
948
+ /**
949
+ * Append-only audit record of an input that the moderation gate blocked.
950
+ *
951
+ * Stores ONLY category flags + numeric scores — never the prompt content or
952
+ * any PII (Q4 data-minimization decision). Enough to identify repeat abusers
953
+ * and tune policy. No `updated_at` / `deleted_at`: rows are immutable. Two
954
+ * indexes back the audit listing (`tenant_id, created_at`) and the per-user
955
+ * abuse review (`tenant_id, user_id`); every read filters `tenant_id`.
956
+ *
957
+ * Spec `2026-06-04-ai-input-moderation-and-safety-identifiers`.
958
+ */
959
+ @Entity({ tableName: 'ai_moderation_flags' })
960
+ @Index({ name: 'ai_moderation_flags_tenant_created_idx', properties: ['tenantId', 'createdAt'] })
961
+ @Index({ name: 'ai_moderation_flags_tenant_user_idx', properties: ['tenantId', 'userId'] })
962
+ export class AiModerationFlag {
963
+ [OptionalProps]?: 'id' | 'createdAt' | 'organizationId'
964
+
965
+ @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
966
+ id!: string
967
+
968
+ @Property({ name: 'tenant_id', type: 'uuid' })
969
+ tenantId!: string
970
+
971
+ @Property({ name: 'organization_id', type: 'uuid', nullable: true })
972
+ organizationId?: string | null
973
+
974
+ @Property({ name: 'agent_id', type: 'text' })
975
+ agentId!: string
976
+
977
+ @Property({ name: 'user_id', type: 'text' })
978
+ userId!: string
979
+
980
+ @Property({ name: 'provider_id', type: 'text' })
981
+ providerId!: string
982
+
983
+ @Property({ name: 'model_id', type: 'text' })
984
+ modelId!: string
985
+
986
+ @Property({ name: 'categories', type: 'jsonb' })
987
+ categories!: Record<string, { flagged: boolean; score: number }>
988
+
989
+ @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
990
+ createdAt: Date = new Date()
991
+ }
@@ -35,6 +35,8 @@ export interface AiAgentRuntimeOverrideInput extends AiAgentRuntimeOverrideLoopI
35
35
  baseURL?: string | null
36
36
  allowedOverrideProviders?: string[] | null
37
37
  allowedOverrideModelsByProvider?: Record<string, string[]>
38
+ /** Input-moderation override: true = on, false = off, null = inherit. */
39
+ inputModeration?: boolean | null
38
40
  /**
39
41
  * Optional: the agent's declared allowedTools. When provided, loopActiveToolsJson
40
42
  * is validated to be a subset. When omitted, allowlist validation is skipped
@@ -270,6 +272,7 @@ export class AiAgentRuntimeOverrideRepository {
270
272
  if ('loopMaxTokens' in input) existing.loopMaxTokens = input.loopMaxTokens ?? null
271
273
  if ('loopStopWhenJson' in input) existing.loopStopWhenJson = input.loopStopWhenJson ?? null
272
274
  if ('loopActiveToolsJson' in input) existing.loopActiveToolsJson = input.loopActiveToolsJson ?? null
275
+ if ('inputModeration' in input) existing.inputModeration = input.inputModeration ?? null
273
276
  await tx.persist(existing).flush()
274
277
  return existing
275
278
  }
@@ -295,6 +298,7 @@ export class AiAgentRuntimeOverrideRepository {
295
298
  loopMaxTokens: input.loopMaxTokens ?? null,
296
299
  loopStopWhenJson: input.loopStopWhenJson ?? null,
297
300
  loopActiveToolsJson: input.loopActiveToolsJson ?? null,
301
+ inputModeration: input.inputModeration ?? null,
298
302
  } as unknown as AiAgentRuntimeOverride)
299
303
  await tx.persist(row).flush()
300
304
  return row