|
|
@@ -1,21 +1,43 @@
|
|
|
+import { HttpClient, HttpHeaders } from '@angular/common/http';
|
|
|
import { Injectable } from '@angular/core';
|
|
|
-import { Observable } from 'rxjs';
|
|
|
-
|
|
|
-const API_BASE = '/fmode-api';
|
|
|
-const MODEL = 'doubao-seed-2-0-lite-260215';
|
|
|
+import { firstValueFrom, Observable } from 'rxjs';
|
|
|
+import { API_CONFIG } from '../../core/app.constants';
|
|
|
+import { SessionService } from '../../core/session.service';
|
|
|
|
|
|
export interface AiStreamMessage {
|
|
|
content: string;
|
|
|
complete: boolean;
|
|
|
}
|
|
|
|
|
|
+interface CloudEnvelope<T> {
|
|
|
+ retcode: number;
|
|
|
+ retmsg?: string;
|
|
|
+ result: T;
|
|
|
+}
|
|
|
+
|
|
|
+interface AiResult {
|
|
|
+ content: string;
|
|
|
+}
|
|
|
+
|
|
|
@Injectable({ providedIn: 'root' })
|
|
|
export class FmodeAiService {
|
|
|
+ constructor(private readonly http: HttpClient, private readonly sessions: SessionService) {}
|
|
|
+
|
|
|
stream(prompt: string): Observable<AiStreamMessage> {
|
|
|
return new Observable((observer) => {
|
|
|
- const controller = new AbortController();
|
|
|
- void this.readStream(prompt, controller.signal, observer).catch((error: unknown) => observer.error(error));
|
|
|
- return () => controller.abort();
|
|
|
+ const subscription = this.request(prompt, false).subscribe({
|
|
|
+ next: (response) => {
|
|
|
+ try {
|
|
|
+ const content = this.unwrap(response);
|
|
|
+ observer.next({ content, complete: true });
|
|
|
+ observer.complete();
|
|
|
+ } catch (error) {
|
|
|
+ observer.error(error);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ error: (error) => observer.error(this.requestError(error)),
|
|
|
+ });
|
|
|
+ return () => subscription.unsubscribe();
|
|
|
});
|
|
|
}
|
|
|
|
|
|
@@ -23,72 +45,40 @@ export class FmodeAiService {
|
|
|
const jsonPrompt = `${prompt}\n请严格按以下结构返回一个完整 JSON 对象,不要使用 Markdown 代码块:\n${schema}`;
|
|
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
|
try {
|
|
|
- const response = await fetch(`${API_BASE}/v1/chat/completions`, {
|
|
|
- method: 'POST',
|
|
|
- headers: { 'Content-Type': 'application/json' },
|
|
|
- body: JSON.stringify({
|
|
|
- model: MODEL,
|
|
|
- messages: [{ role: 'user', content: jsonPrompt }],
|
|
|
- stream: false,
|
|
|
- max_tokens: 32768,
|
|
|
- response_format: { type: 'json_object' },
|
|
|
- thinking: { type: 'disabled' },
|
|
|
- }),
|
|
|
- });
|
|
|
- if (!response.ok) throw new Error(await this.apiError(response));
|
|
|
- const data = await response.json() as { choices?: Array<{ message?: { content?: string } }> };
|
|
|
- const content = data.choices?.[0]?.message?.content ?? '';
|
|
|
+ const response = await firstValueFrom(this.request(jsonPrompt, true));
|
|
|
+ const content = this.unwrap(response);
|
|
|
onMessage?.(content);
|
|
|
return this.extractJson(content);
|
|
|
} catch (error) {
|
|
|
- if (attempt === retries) throw error;
|
|
|
+ if (attempt === retries) throw this.requestError(error);
|
|
|
}
|
|
|
}
|
|
|
throw new Error('题目生成失败');
|
|
|
}
|
|
|
|
|
|
- private async readStream(prompt: string, signal: AbortSignal, observer: {
|
|
|
- next(value: AiStreamMessage): void; complete(): void; error(error: unknown): void;
|
|
|
- }): Promise<void> {
|
|
|
- const response = await fetch(`${API_BASE}/v1/chat/completions`, {
|
|
|
- method: 'POST',
|
|
|
- headers: { 'Content-Type': 'application/json' },
|
|
|
- signal,
|
|
|
- body: JSON.stringify({
|
|
|
- model: MODEL,
|
|
|
- messages: [{ role: 'user', content: prompt }],
|
|
|
- stream: true,
|
|
|
- max_tokens: 32768,
|
|
|
- thinking: { type: 'disabled' },
|
|
|
- }),
|
|
|
+ private request(prompt: string, jsonMode: boolean): Observable<CloudEnvelope<AiResult>> {
|
|
|
+ const token = String(this.sessions.token() || '').trim();
|
|
|
+ if (!token.startsWith('r:')) throw new Error('阅读学习需要登录,请退出后重新登录');
|
|
|
+ return this.http.post<CloudEnvelope<AiResult>>(API_CONFIG.cloudFunctionUrl, {
|
|
|
+ token,
|
|
|
+ params: { action: 'reading/ai', prompt, jsonMode },
|
|
|
+ }, {
|
|
|
+ headers: new HttpHeaders({ 'X-Parse-Application-Id': API_CONFIG.parseAppId, 'Content-Type': 'application/json' }),
|
|
|
});
|
|
|
- if (!response.ok) throw new Error(await this.apiError(response));
|
|
|
- if (!response.body) throw new Error('生成服务未返回内容');
|
|
|
+ }
|
|
|
|
|
|
- const reader = response.body.getReader();
|
|
|
- const decoder = new TextDecoder();
|
|
|
- let buffer = '';
|
|
|
- let content = '';
|
|
|
- let done = false;
|
|
|
- while (!done) {
|
|
|
- const chunk = await reader.read();
|
|
|
- done = chunk.done;
|
|
|
- buffer += decoder.decode(chunk.value, { stream: !done });
|
|
|
- const lines = buffer.split('\n');
|
|
|
- buffer = lines.pop() ?? '';
|
|
|
- for (const line of lines) {
|
|
|
- if (!line.startsWith('data:')) continue;
|
|
|
- const payload = line.slice(5).trim();
|
|
|
- if (!payload || payload === '[DONE]') continue;
|
|
|
- try {
|
|
|
- const parsed = JSON.parse(payload) as { choices?: Array<{ delta?: { content?: string } }> };
|
|
|
- content += parsed.choices?.[0]?.delta?.content ?? '';
|
|
|
- observer.next({ content, complete: false });
|
|
|
- } catch { /* Ignore malformed event data. */ }
|
|
|
- }
|
|
|
- }
|
|
|
- observer.next({ content, complete: true });
|
|
|
- observer.complete();
|
|
|
+ private unwrap(response: CloudEnvelope<AiResult>): string {
|
|
|
+ if (response.retcode === -1) throw new Error(response.retmsg || '阅读生成服务请求失败');
|
|
|
+ const content = String(response.result?.content || '');
|
|
|
+ if (!content.trim()) throw new Error('阅读生成服务未返回内容');
|
|
|
+ return content;
|
|
|
+ }
|
|
|
+
|
|
|
+ private requestError(error: unknown): Error {
|
|
|
+ const cloudMessage = (error as { error?: { retmsg?: string } } | null)?.error?.retmsg;
|
|
|
+ if (cloudMessage) return new Error(cloudMessage);
|
|
|
+ if (error instanceof Error && error.message) return error;
|
|
|
+ return new Error('阅读生成服务暂时不可用,请稍后重试');
|
|
|
}
|
|
|
|
|
|
private extractJson(content: string): Record<string, unknown> {
|
|
|
@@ -148,10 +138,4 @@ export class FmodeAiService {
|
|
|
}
|
|
|
return '';
|
|
|
}
|
|
|
-
|
|
|
- private async apiError(response: Response): Promise<string> {
|
|
|
- const message = await response.text().catch(() => '');
|
|
|
- if (response.status === 503 && message.includes('FMODE_API_TOKEN')) return '阅读生成服务尚未配置,请联系管理员';
|
|
|
- return `生成服务请求失败(${response.status})`;
|
|
|
- }
|
|
|
}
|