feat(teacher): 连接 Yuxi 老师并提供项目与会话只读工具

This commit is contained in:
2026-09-23 09:56:11 +08:00
parent 1f2ad3fb3a
commit e35a13079e
14 changed files with 886 additions and 30 deletions

View File

@@ -1,6 +1,7 @@
import { hostApiFetch, createHostEventSource, ensureHostApiToken } from './host-api';
import type {
TeacherAvailability,
TeacherCatalog,
TeacherDefinition,
TeacherSend,
TeacherTopic,
@@ -12,6 +13,7 @@ export function teacherTopicsPath(projectId: string, sourceId: string) {
: `/api/coding/projects/${encodeURIComponent(projectId)}/conversations/${encodeURIComponent(sourceId)}/teacher-topics`;
}
export const teacherApi = {
catalog: () => hostApiFetch<TeacherCatalog>('/api/coding/teacher/teachers'),
config: () =>
hostApiFetch<TeacherAvailability & { definition: TeacherDefinition | null }>(
'/api/coding/teacher/config'
@@ -21,10 +23,10 @@ export const teacherApi = {
'/api/coding/teacher-preview?draftRevision=' + draftRevision
),
list: (base: string) => hostApiFetch<TeacherTopicList>(base),
create: (base: string, draftRevision?: number, sampleContext?: string) =>
create: (base: string, draftRevision?: number, sampleContext?: string, teacherVersion?: number) =>
hostApiFetch<TeacherTopic>(base, {
method: 'POST',
body: JSON.stringify({ draftRevision, sampleContext }),
body: JSON.stringify({ draftRevision, sampleContext, teacherVersion }),
}),
read: (base: string, id: string) =>
hostApiFetch<TeacherTopic>(base + '/' + encodeURIComponent(id)),

View File

@@ -3,6 +3,7 @@ import { MessageCircle, Plus, X } from 'lucide-react';
import { teacherApi, teacherTopicsPath } from '@/lib/coding-teacher';
import type {
TeacherDefinition,
TeacherCatalog,
TeacherReference,
TeacherSend,
TeacherTopic,
@@ -36,6 +37,9 @@ export function TeacherChatPanel({
const [error, setError] = useState('');
const [busy, setBusy] = useState(true);
const [enabled, setEnabled] = useState(true);
const [legacyEnabled, setLegacyEnabled] = useState(true);
const [teachers, setTeachers] = useState<TeacherCatalog['items']>([]);
const [selectedTeacherVersion, setSelectedTeacherVersion] = useState<number>();
const pending = useRef<TeacherSend | null>(null);
const generation = useRef(0);
const topicRef = useRef(topic);
@@ -66,6 +70,17 @@ export function TeacherChatPanel({
if (alive) {
setDefinition(config.definition);
setEnabled(config.enabled);
setLegacyEnabled(config.enabled);
}
if (!draftRevision) {
const catalog = await teacherApi.catalog();
if (alive) {
setTeachers(catalog.items);
const selected = catalog.items.find((item) => item.is_default) ?? catalog.items[0];
setSelectedTeacherVersion(selected?.version);
setEnabled(catalog.items.length > 0);
if (selected) setDefinition(selected.definition);
}
}
} catch (e) {
if (alive) setError(e instanceof Error ? e.message : '老师配置暂不可用');
@@ -155,7 +170,12 @@ export function TeacherChatPanel({
setBusy(true);
setError('');
try {
const next = await teacherApi.create(base, draftRevision, sampleContext);
const next = await teacherApi.create(
base,
draftRevision,
sampleContext,
selectedTeacherVersion
);
if (version === generation.current) {
consume(next);
setDefinition(next.definition);
@@ -177,9 +197,15 @@ export function TeacherChatPanel({
let current = topicRef.current;
try {
if (!current) {
current = await teacherApi.create(base, draftRevision, sampleContext);
current = await teacherApi.create(
base,
draftRevision,
sampleContext,
selectedTeacherVersion
);
if (version !== generation.current) return;
consume(current);
setDefinition(current.definition);
}
const previous = pending.current;
const input =
@@ -206,6 +232,11 @@ export function TeacherChatPanel({
const running = topic?.requests.find(
(request) => request.status === 'preparing' || request.status === 'running'
);
const topicEnabled = !topic
? enabled
: topic.definition.config_id
? teachers.some((item) => item.teacher_id === topic.definition.config_id)
: legacyEnabled;
return (
<aside
className="flex h-full min-h-0 w-full flex-col border-l border-border bg-background"
@@ -243,6 +274,25 @@ export function TeacherChatPanel({
</button>
)}
</header>
{!draftRevision && teachers.length > 0 && (
<label className="mx-3 mt-2 text-xs text-muted-foreground">
新话题使用的老师
<select
aria-label="新话题使用的老师"
value={selectedTeacherVersion ?? ''}
disabled={busy}
onChange={(event) => setSelectedTeacherVersion(Number(event.target.value))}
className="mt-1 w-full rounded border bg-background p-2 text-foreground"
>
{teachers.map((item) => (
<option key={item.teacher_id} value={item.version}>
{item.definition.name}
{item.is_default ? '(默认)' : ''}
</option>
))}
</select>
</label>
)}
{topics.items.length > 0 && (
<select
aria-label="老师话题"
@@ -293,6 +343,11 @@ export function TeacherChatPanel({
{request.response ||
(['running', 'preparing'].includes(request.status) ? '老师正在思考…' : '')}
</div>
{request.progress && ['running', 'preparing'].includes(request.status) && (
<p role="status" className="text-xs text-muted-foreground">
{request.progress}
</p>
)}
{request.omittedMessages > 0 && (
<p className="text-xs text-muted-foreground">
本轮上下文已省略 {request.omittedMessages} 条较早消息。
@@ -300,7 +355,9 @@ export function TeacherChatPanel({
)}
{(request.truncatedMessages ?? 0) > 0 && (
<p className="text-xs text-muted-foreground">
{draftRevision ? '较长的上下文已节选。' : '较长的上下文已节选,老师可按需读取原文。'}
{draftRevision
? '较长的上下文已节选。'
: '较长的上下文已节选,老师可按需读取原文。'}
</p>
)}
{request.error && <p className="text-xs text-destructive">{request.error}</p>}
@@ -338,7 +395,7 @@ export function TeacherChatPanel({
</button>
</div>
)}
{!enabled && (
{!topicEnabled && (
<p className="px-4 text-xs text-muted-foreground">老师暂未开放,历史仍可查看。</p>
)}
<form
@@ -385,7 +442,7 @@ export function TeacherChatPanel({
) : (
<button
type="submit"
disabled={busy || !enabled || !text.trim()}
disabled={busy || !topicEnabled || !text.trim()}
className="rounded-lg bg-brand px-4 py-2 text-xs text-white disabled:opacity-40"
>
{busy ? '正在发送…' : '提问'}