feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { ZAiTutorSettingsUpdate, ZCourseAiTutorOverride } from '@cio/utils/validation/agent';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
import type {
|
||||
CourseAiTutorOverride,
|
||||
CourseAiTutorView,
|
||||
GetCourseAiTutorRequest,
|
||||
GetOrgAiTutorRequest,
|
||||
OrgAiTutorSettings,
|
||||
UpdateCourseAiTutorRequest,
|
||||
UpdateOrgAiTutorRequest
|
||||
} from '../utils/types';
|
||||
|
||||
class AiTutorApi extends BaseApiWithErrors {
|
||||
orgSettings = $state<OrgAiTutorSettings | null>(null);
|
||||
courseSettings = $state<CourseAiTutorView | null>(null);
|
||||
loading = $state(false);
|
||||
saving = $state(false);
|
||||
|
||||
async fetchOrgSettings() {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
await this.execute<GetOrgAiTutorRequest>({
|
||||
requestFn: () => classroomio.organization['ai-tutor'].$get(),
|
||||
logContext: 'fetching org AI tutor settings',
|
||||
onSuccess: (response) => {
|
||||
this.orgSettings = response.data;
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async updateOrgSettings(patch: Partial<OrgAiTutorSettings>) {
|
||||
const result = ZAiTutorSettingsUpdate.safeParse(patch);
|
||||
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
await this.execute<UpdateOrgAiTutorRequest>({
|
||||
requestFn: () => classroomio.organization['ai-tutor'].$put({ json: result.data }),
|
||||
logContext: 'updating org AI tutor settings',
|
||||
onSuccess: (response) => {
|
||||
this.orgSettings = response.data;
|
||||
this.errors = {};
|
||||
snackbar.success('aiTutor.snackbar.saved');
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result !== 'string' && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
}
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchCourseSettings(courseId: string) {
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
await this.execute<GetCourseAiTutorRequest>({
|
||||
requestFn: () => classroomio.course[':courseId']['ai-tutor'].$get({ param: { courseId } }),
|
||||
logContext: 'fetching course AI tutor settings',
|
||||
onSuccess: (response) => {
|
||||
this.courseSettings = response.data;
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async updateCourseSettings(courseId: string, patch: CourseAiTutorOverride) {
|
||||
const result = ZCourseAiTutorOverride.safeParse(patch ?? {});
|
||||
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
await this.execute<UpdateCourseAiTutorRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId']['ai-tutor'].$put({
|
||||
param: { courseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'updating course AI tutor settings',
|
||||
onSuccess: (response) => {
|
||||
if (this.courseSettings) {
|
||||
this.courseSettings = { ...this.courseSettings, override: response.data };
|
||||
}
|
||||
this.errors = {};
|
||||
snackbar.success('aiTutor.snackbar.saved');
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const aiTutorApi = new AiTutorApi();
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
<script lang="ts">
|
||||
import type { Writable } from 'svelte/store';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { AiTutorSettings } from '@cio/ai-assistant/tutor-config';
|
||||
|
||||
interface Props {
|
||||
store: Writable<AiTutorSettings>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { store, disabled = false }: Props = $props();
|
||||
|
||||
const personaOptions = [
|
||||
{ value: 'encouraging', labelKey: 'aiTutor.persona.encouraging' as const },
|
||||
{ value: 'friendly', labelKey: 'aiTutor.persona.friendly' as const },
|
||||
{ value: 'formal', labelKey: 'aiTutor.persona.formal' as const },
|
||||
{ value: 'socratic', labelKey: 'aiTutor.persona.socratic' as const },
|
||||
{ value: 'custom', labelKey: 'aiTutor.persona.custom' as const }
|
||||
];
|
||||
|
||||
const responseLengthOptions = [
|
||||
{ value: 'short', labelKey: 'aiTutor.responseLength.short' as const },
|
||||
{ value: 'medium', labelKey: 'aiTutor.responseLength.medium' as const },
|
||||
{ value: 'long', labelKey: 'aiTutor.responseLength.long' as const }
|
||||
];
|
||||
|
||||
const assessmentModeOptions = [
|
||||
{ value: 'hint_only', labelKey: 'aiTutor.assessmentMode.hint_only' as const },
|
||||
{ value: 'direct_answer', labelKey: 'aiTutor.assessmentMode.direct_answer' as const },
|
||||
{ value: 'block_during_exercise', labelKey: 'aiTutor.assessmentMode.block_during_exercise' as const }
|
||||
];
|
||||
|
||||
const codePolicyOptions = [
|
||||
{ value: 'hints_only', labelKey: 'aiTutor.codePolicy.hints_only' as const },
|
||||
{ value: 'allowed', labelKey: 'aiTutor.codePolicy.allowed' as const },
|
||||
{ value: 'forbidden', labelKey: 'aiTutor.codePolicy.forbidden' as const }
|
||||
];
|
||||
|
||||
const groundingScopeOptions = [
|
||||
{ value: 'course', labelKey: 'aiTutor.groundingScope.course' as const },
|
||||
{ value: 'lesson', labelKey: 'aiTutor.groundingScope.lesson' as const },
|
||||
{ value: 'workspace', labelKey: 'aiTutor.groundingScope.workspace' as const }
|
||||
];
|
||||
|
||||
let forbiddenTopicsInput = $state($store.forbiddenTopics.join(', '));
|
||||
|
||||
$effect(() => {
|
||||
forbiddenTopicsInput = $store.forbiddenTopics.join(', ');
|
||||
});
|
||||
|
||||
function syncForbiddenTopics() {
|
||||
const list = forbiddenTopicsInput
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
.slice(0, 20);
|
||||
store.update((value) => ({ ...value, forbiddenTopics: list }));
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('aiTutor.section.availability')}</Field.Legend>
|
||||
<Field.Description>{$t('aiTutor.section.availability_description')}</Field.Description>
|
||||
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$store.enabled} {disabled} />
|
||||
<Field.Label>{$t('aiTutor.field.enabled')}</Field.Label>
|
||||
</Field.Field>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('aiTutor.section.personality')}</Field.Legend>
|
||||
<Field.Description>{$t('aiTutor.section.personality_description')}</Field.Description>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('aiTutor.field.persona')}</Field.Label>
|
||||
<Select.Root type="single" bind:value={$store.persona} {disabled}>
|
||||
<Select.Trigger
|
||||
>{$t(
|
||||
personaOptions.find((o) => o.value === $store.persona)?.labelKey ?? 'aiTutor.persona.encouraging'
|
||||
)}</Select.Trigger
|
||||
>
|
||||
<Select.Content>
|
||||
{#each personaOptions as opt (opt.value)}
|
||||
<Select.Item value={opt.value}>{$t(opt.labelKey)}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Field.Field>
|
||||
|
||||
{#if $store.persona === 'custom'}
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('aiTutor.field.customPersona')}</Field.Label>
|
||||
<Textarea rows={3} bind:value={$store.customPersona} {disabled} />
|
||||
<Field.Description>{$t('aiTutor.field.customPersona_description')}</Field.Description>
|
||||
</Field.Field>
|
||||
{/if}
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('aiTutor.field.responseLength')}</Field.Label>
|
||||
<Select.Root type="single" bind:value={$store.responseLength} {disabled}>
|
||||
<Select.Trigger
|
||||
>{$t(
|
||||
responseLengthOptions.find((o) => o.value === $store.responseLength)?.labelKey ??
|
||||
'aiTutor.responseLength.medium'
|
||||
)}</Select.Trigger
|
||||
>
|
||||
<Select.Content>
|
||||
{#each responseLengthOptions as opt (opt.value)}
|
||||
<Select.Item value={opt.value}>{$t(opt.labelKey)}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('aiTutor.field.welcomeMessage')}</Field.Label>
|
||||
<Textarea rows={2} maxlength={500} bind:value={$store.welcomeMessage} {disabled} />
|
||||
<Field.Description>{$t('aiTutor.field.welcomeMessage_description')}</Field.Description>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('aiTutor.field.disclaimerFooter')}</Field.Label>
|
||||
<Input maxlength={200} bind:value={$store.disclaimerFooter} {disabled} />
|
||||
<Field.Description>{$t('aiTutor.field.disclaimerFooter_description')}</Field.Description>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('aiTutor.section.guidance')}</Field.Legend>
|
||||
<Field.Description>{$t('aiTutor.section.guidance_description')}</Field.Description>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('aiTutor.field.thingsToSay')}</Field.Label>
|
||||
<Textarea rows={3} maxlength={2000} bind:value={$store.thingsToSay} {disabled} />
|
||||
<Field.Description>{$t('aiTutor.field.thingsToSay_description')}</Field.Description>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('aiTutor.field.thingsNotToSay')}</Field.Label>
|
||||
<Textarea rows={3} maxlength={2000} bind:value={$store.thingsNotToSay} {disabled} />
|
||||
<Field.Description>{$t('aiTutor.field.thingsNotToSay_description')}</Field.Description>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('aiTutor.field.forbiddenTopics')}</Field.Label>
|
||||
<Input
|
||||
bind:value={forbiddenTopicsInput}
|
||||
onblur={syncForbiddenTopics}
|
||||
placeholder={$t('aiTutor.field.forbiddenTopics_placeholder')}
|
||||
{disabled}
|
||||
/>
|
||||
<Field.Description>{$t('aiTutor.field.forbiddenTopics_description')}</Field.Description>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$store.blockOffTopic} {disabled} />
|
||||
<Label>{$t('aiTutor.field.blockOffTopic')}</Label>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$store.profanityFilter} {disabled} />
|
||||
<Label>{$t('aiTutor.field.profanityFilter')}</Label>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('aiTutor.section.assessment')}</Field.Legend>
|
||||
<Field.Description>{$t('aiTutor.section.assessment_description')}</Field.Description>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('aiTutor.field.assessmentMode')}</Field.Label>
|
||||
<Select.Root type="single" bind:value={$store.assessmentMode} {disabled}>
|
||||
<Select.Trigger
|
||||
>{$t(
|
||||
assessmentModeOptions.find((o) => o.value === $store.assessmentMode)?.labelKey ??
|
||||
'aiTutor.assessmentMode.hint_only'
|
||||
)}</Select.Trigger
|
||||
>
|
||||
<Select.Content>
|
||||
{#each assessmentModeOptions as opt (opt.value)}
|
||||
<Select.Item value={opt.value}>{$t(opt.labelKey)}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('aiTutor.field.revealSolutionsAfterAttempts')}</Field.Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="20"
|
||||
value={$store.revealSolutionsAfterAttempts}
|
||||
oninput={(e) => {
|
||||
const v = Number((e.target as HTMLInputElement).value);
|
||||
if (Number.isFinite(v)) store.update((s) => ({ ...s, revealSolutionsAfterAttempts: v }));
|
||||
}}
|
||||
{disabled}
|
||||
/>
|
||||
<Field.Description>{$t('aiTutor.field.revealSolutionsAfterAttempts_description')}</Field.Description>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('aiTutor.field.codePolicy')}</Field.Label>
|
||||
<Select.Root type="single" bind:value={$store.codePolicy} {disabled}>
|
||||
<Select.Trigger
|
||||
>{$t(
|
||||
codePolicyOptions.find((o) => o.value === $store.codePolicy)?.labelKey ?? 'aiTutor.codePolicy.hints_only'
|
||||
)}</Select.Trigger
|
||||
>
|
||||
<Select.Content>
|
||||
{#each codePolicyOptions as opt (opt.value)}
|
||||
<Select.Item value={opt.value}>{$t(opt.labelKey)}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('aiTutor.section.grounding')}</Field.Legend>
|
||||
<Field.Description>{$t('aiTutor.section.grounding_description')}</Field.Description>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('aiTutor.field.groundingScope')}</Field.Label>
|
||||
<Select.Root type="single" bind:value={$store.groundingScope} {disabled}>
|
||||
<Select.Trigger
|
||||
>{$t(
|
||||
groundingScopeOptions.find((o) => o.value === $store.groundingScope)?.labelKey ??
|
||||
'aiTutor.groundingScope.course'
|
||||
)}</Select.Trigger
|
||||
>
|
||||
<Select.Content>
|
||||
{#each groundingScopeOptions as opt (opt.value)}
|
||||
<Select.Item value={opt.value}>{$t(opt.labelKey)}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$store.requireCitations} {disabled} />
|
||||
<Label>{$t('aiTutor.field.requireCitations')}</Label>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('aiTutor.section.escalation')}</Field.Legend>
|
||||
<Field.Description>{$t('aiTutor.section.escalation_description')}</Field.Description>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$store.escalation.enabled} {disabled} />
|
||||
<Label>{$t('aiTutor.field.escalationEnabled')}</Label>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('aiTutor.field.escalationEmail')}</Field.Label>
|
||||
<Input
|
||||
type="email"
|
||||
bind:value={$store.escalation.email}
|
||||
placeholder="[email protected]"
|
||||
disabled={disabled || !$store.escalation.enabled}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { aiTutorApi } from './api/ai-tutor.svelte';
|
||||
export { default as OrgAiTutorSettingsPage } from './pages/org-settings.svelte';
|
||||
export { default as CourseAiTutorSettingsPage } from './pages/course-settings.svelte';
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
import { defaultAiTutorSettings, type AiTutorSettings } from '@cio/ai-assistant/tutor-config';
|
||||
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { aiTutorApi } from '../api/ai-tutor.svelte';
|
||||
import TutorSettingsForm from '../components/tutor-settings-form.svelte';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
}
|
||||
|
||||
let { courseId }: Props = $props();
|
||||
|
||||
const formStore = writable<AiTutorSettings>({ ...defaultAiTutorSettings });
|
||||
let inheritFromOrg = $state(true);
|
||||
let initialized = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await aiTutorApi.fetchCourseSettings(courseId);
|
||||
const view = aiTutorApi.courseSettings;
|
||||
|
||||
if (view) {
|
||||
formStore.set(view.effective ?? defaultAiTutorSettings);
|
||||
inheritFromOrg = view.override == null || view.override.inheritFromOrg !== false;
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
});
|
||||
|
||||
async function handleSave() {
|
||||
if (inheritFromOrg) {
|
||||
await aiTutorApi.updateCourseSettings(courseId, { inheritFromOrg: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = $formStore;
|
||||
await aiTutorApi.updateCourseSettings(courseId, { ...data, inheritFromOrg: false });
|
||||
}
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
<Page.Header isSticky class="ui:bg-background z-10">
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('aiTutor.page.course.title')}</Page.Title>
|
||||
<Page.Subtitle>{$t('aiTutor.page.course.description')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button loading={aiTutorApi.saving} disabled={aiTutorApi.saving || !initialized} onclick={handleSave}>
|
||||
{$t('aiTutor.action.save')}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
{#if !initialized}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('aiTutor.state.loading')}</p>
|
||||
{:else}
|
||||
<Field.Group>
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('aiTutor.section.inheritance')}</Field.Legend>
|
||||
<Field.Description>{$t('aiTutor.section.inheritance_description')}</Field.Description>
|
||||
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={inheritFromOrg} />
|
||||
<Label>{$t('aiTutor.field.inheritFromOrg')}</Label>
|
||||
</Field.Field>
|
||||
</Field.Set>
|
||||
|
||||
{#if !inheritFromOrg}
|
||||
<Field.Separator />
|
||||
<TutorSettingsForm store={formStore} disabled={aiTutorApi.saving} />
|
||||
{/if}
|
||||
</Field.Group>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { defaultAiTutorSettings } from '@cio/ai-assistant/tutor-config';
|
||||
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { aiTutorApi } from '../api/ai-tutor.svelte';
|
||||
import { applyOrgSettings, orgTutorSettingsStore } from '../store/tutor-settings-store';
|
||||
import TutorSettingsForm from '../components/tutor-settings-form.svelte';
|
||||
|
||||
let initialized = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await aiTutorApi.fetchOrgSettings();
|
||||
applyOrgSettings(aiTutorApi.orgSettings ?? defaultAiTutorSettings);
|
||||
initialized = true;
|
||||
});
|
||||
|
||||
async function handleSave() {
|
||||
await aiTutorApi.updateOrgSettings($orgTutorSettingsStore);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
<Page.Header isSticky class="ui:bg-background z-10">
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('aiTutor.page.org.title')}</Page.Title>
|
||||
<Page.Subtitle>{$t('aiTutor.page.org.description')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button loading={aiTutorApi.saving} disabled={aiTutorApi.saving || !initialized} onclick={handleSave}>
|
||||
{$t('aiTutor.action.save')}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
{#if !initialized}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('aiTutor.state.loading')}</p>
|
||||
{:else}
|
||||
<TutorSettingsForm store={orgTutorSettingsStore} disabled={aiTutorApi.saving} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { defaultAiTutorSettings, type AiTutorSettings } from '@cio/ai-assistant/tutor-config';
|
||||
|
||||
export type AiTutorFormState = AiTutorSettings;
|
||||
|
||||
export const orgTutorSettingsStore = writable<AiTutorFormState>({ ...defaultAiTutorSettings });
|
||||
|
||||
export interface CourseTutorFormState {
|
||||
override: AiTutorSettings;
|
||||
inheritFromOrg: boolean;
|
||||
}
|
||||
|
||||
export const courseTutorSettingsStore = writable<CourseTutorFormState>({
|
||||
override: { ...defaultAiTutorSettings },
|
||||
inheritFromOrg: true
|
||||
});
|
||||
|
||||
export function applyOrgSettings(settings: AiTutorSettings) {
|
||||
orgTutorSettingsStore.set({ ...defaultAiTutorSettings, ...settings });
|
||||
}
|
||||
|
||||
export function applyCourseSettings(
|
||||
effective: AiTutorSettings,
|
||||
override: (Partial<AiTutorSettings> & { inheritFromOrg?: boolean }) | null
|
||||
) {
|
||||
courseTutorSettingsStore.set({
|
||||
override: { ...effective, ...(override ?? {}) },
|
||||
inheritFromOrg: override?.inheritFromOrg !== false && override == null ? true : (override?.inheritFromOrg ?? false)
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
export type GetOrgAiTutorRequest = (typeof classroomio.organization)['ai-tutor']['$get'];
|
||||
export type UpdateOrgAiTutorRequest = (typeof classroomio.organization)['ai-tutor']['$put'];
|
||||
|
||||
export type GetCourseAiTutorRequest = (typeof classroomio.course)[':courseId']['ai-tutor']['$get'];
|
||||
export type UpdateCourseAiTutorRequest = (typeof classroomio.course)[':courseId']['ai-tutor']['$put'];
|
||||
|
||||
export type GetOrgAiTutorSuccess = Extract<InferResponseType<GetOrgAiTutorRequest>, { success: true }>;
|
||||
export type OrgAiTutorSettings = GetOrgAiTutorSuccess['data'];
|
||||
|
||||
export type GetCourseAiTutorSuccess = Extract<InferResponseType<GetCourseAiTutorRequest>, { success: true }>;
|
||||
export type CourseAiTutorView = GetCourseAiTutorSuccess['data'];
|
||||
export type CourseAiTutorOverride = CourseAiTutorView['override'];
|
||||
|
||||
export type GetTutorLeaderboardRequest = (typeof classroomio.agent)['tutor-usage']['leaderboard']['$get'];
|
||||
export type GetTutorSummaryRequest = (typeof classroomio.agent)['tutor-usage']['summary']['$get'];
|
||||
export type GetTutorLearnerDetailRequest = (typeof classroomio.agent)['tutor-usage'][':userId']['$get'];
|
||||
|
||||
export type TutorLeaderboardSuccess = Extract<InferResponseType<GetTutorLeaderboardRequest>, { success: true }>;
|
||||
export type TutorLeaderboardData = TutorLeaderboardSuccess['data'];
|
||||
|
||||
export type TutorSummarySuccess = Extract<InferResponseType<GetTutorSummaryRequest>, { success: true }>;
|
||||
export type TutorSummaryData = TutorSummarySuccess['data'];
|
||||
|
||||
export type TutorLearnerDetailSuccess = Extract<InferResponseType<GetTutorLearnerDetailRequest>, { success: true }>;
|
||||
export type TutorLearnerDetailData = TutorLearnerDetailSuccess['data'];
|
||||
Reference in New Issue
Block a user