fix(frontend): 同步 main 并修复 phase2 关闭审阅意见
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
import { SseClient } from './sseClient'
|
||||
import type { ModelEvent } from '@/contracts'
|
||||
import { apiClient } from './apiClient'
|
||||
import type { ChatMessage, Conversation, ModelEvent, PageMeta } from '@/contracts'
|
||||
|
||||
export interface ChatRequest {
|
||||
provider_id: string
|
||||
model: string
|
||||
conversation_id?: string
|
||||
user_message_id?: string
|
||||
assistant_message_id?: string
|
||||
conversation_title?: string
|
||||
system?: string
|
||||
messages: Array<{
|
||||
role: 'system' | 'user' | 'assistant' | 'tool'
|
||||
@@ -18,6 +22,25 @@ export interface ChatRequest {
|
||||
max_tokens?: number
|
||||
}
|
||||
|
||||
export function listConversations(offset = 0, limit = 100) {
|
||||
return apiClient.get<{ items: Conversation[]; page: PageMeta }>('/api/chat/conversations', { params: { limit, offset } })
|
||||
}
|
||||
|
||||
export function createConversation(conversation: Pick<Conversation, 'conversation_id' | 'title'>) {
|
||||
return apiClient.post<Conversation>('/api/chat/conversations', {
|
||||
conversation_id: conversation.conversation_id,
|
||||
title: conversation.title,
|
||||
})
|
||||
}
|
||||
|
||||
export function listConversationMessages(conversationId: string, offset = 0, limit = 500) {
|
||||
return apiClient.get<{ items: ChatMessage[]; page: PageMeta }>(`/api/chat/conversations/${encodeURIComponent(conversationId)}/messages`, { params: { limit, offset } })
|
||||
}
|
||||
|
||||
export function removeConversation(conversationId: string) {
|
||||
return apiClient.delete(`/api/chat/conversations/${encodeURIComponent(conversationId)}`)
|
||||
}
|
||||
|
||||
export function streamChat(
|
||||
request: ChatRequest,
|
||||
handlers: {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { createMediaSubmission, mediaService, type MediaJob } from './mediaService'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('reuses upload and job identities after lost responses, until explicitly reset', async () => {
|
||||
const upload = vi.spyOn(mediaService, 'upload').mockRejectedValueOnce(new Error('response lost'))
|
||||
.mockResolvedValue({attachment_id:'uploaded'})
|
||||
const create = vi.spyOn(mediaService, 'create').mockRejectedValueOnce(new Error('response lost'))
|
||||
.mockResolvedValue({job_id:'same-job'} as MediaJob)
|
||||
const submission = createMediaSubmission()
|
||||
const file = new File(['audio'], 'lecture.wav')
|
||||
const options = {local_only:true}
|
||||
await expect(submission.submit(file, options)).rejects.toThrow('response lost')
|
||||
await expect(submission.submit(file, options)).rejects.toThrow('response lost')
|
||||
expect(await submission.submit(file, options)).toEqual({job_id:'same-job'})
|
||||
expect(upload).toHaveBeenCalledTimes(2)
|
||||
expect(upload.mock.calls[0][1]).toBe(upload.mock.calls[1][1])
|
||||
expect(create.mock.calls[0][0]).toEqual(create.mock.calls[1][0])
|
||||
submission.reset()
|
||||
await submission.submit(file, options)
|
||||
expect(upload.mock.calls[2][1]).not.toBe(upload.mock.calls[1][1])
|
||||
expect(create.mock.calls[2][0]).not.toEqual(create.mock.calls[1][0])
|
||||
})
|
||||
|
||||
it('freezes options across upload and treats changed options as a new request', async () => {
|
||||
let release!: (value:{attachment_id:string}) => void
|
||||
vi.spyOn(mediaService, 'upload').mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
|
||||
.mockResolvedValue({attachment_id:'next'})
|
||||
const create = vi.spyOn(mediaService, 'create').mockResolvedValue({job_id:'job'} as MediaJob)
|
||||
const submission = createMediaSubmission()
|
||||
const file = new File(['audio'], 'lecture.wav')
|
||||
const options = {local_only:true}
|
||||
const pending = submission.submit(file, options)
|
||||
options.local_only = false
|
||||
release({attachment_id:'first'})
|
||||
await pending
|
||||
expect(create.mock.calls[0][0]).toMatchObject({local_only:true})
|
||||
await submission.submit(file, options)
|
||||
expect(create.mock.calls[1][0]).toMatchObject({local_only:false})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiClient, resolveApiUrl } from './apiClient'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
export interface Segment { segment_id: string; start_time: number; end_time: number; text: string; speaker: string | null; language?: string }
|
||||
export interface MediaJob {
|
||||
@@ -18,15 +19,33 @@ export const mediaService = {
|
||||
revision: job.revision, text: job.text, segments: job.segments, speaker_names: job.speaker_names,
|
||||
}),
|
||||
revisions: (id: string) => apiClient.get<{items: MediaJob[]}>(`/api/media/transcriptions/${encodeURIComponent(id)}/revisions`),
|
||||
note: (id: string, title: string) => apiClient.post<{note_id: string; title: string}>(`/api/media/transcriptions/${encodeURIComponent(id)}/notes`, { title }),
|
||||
note: (id: string, title: string, update_existing = false) => apiClient.post<{note_id: string; title: string}>(`/api/media/transcriptions/${encodeURIComponent(id)}/notes`, { title, update_existing }),
|
||||
audio: (id: string) => resolveApiUrl(`/api/media/attachments/${encodeURIComponent(id)}`),
|
||||
impact: (id: string) => apiClient.get<{message:string;retained_note_ids:string[]}>(`/api/media/attachments/${encodeURIComponent(id)}/cleanup-impact`),
|
||||
purge: (id: string) => apiClient.delete(`/api/media/attachments/${encodeURIComponent(id)}`),
|
||||
async upload(file: File) {
|
||||
async upload(file: File, idempotencyKey?: string) {
|
||||
const response = await fetch(resolveApiUrl(`/api/media/attachments?filename=${encodeURIComponent(file.name)}`), {
|
||||
method: 'POST', headers: {'Content-Type': 'application/octet-stream'}, body: file,
|
||||
method: 'POST', headers: {'Content-Type': 'application/octet-stream', ...(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {})}, body: file,
|
||||
})
|
||||
if (!response.ok) throw new Error((await response.json())?.error?.message || '附件上传失败')
|
||||
if (!response.ok) throw new Error((await response.json())?.error?.message || t('附件上传失败', 'Attachment upload failed'))
|
||||
return await response.json() as {attachment_id: string}
|
||||
},
|
||||
}
|
||||
|
||||
// Keep one identity until the input/options change, including a lost HTTP response.
|
||||
// Payloads remain in memory; durable uploads/jobs are owned by the backend.
|
||||
export function createMediaSubmission() {
|
||||
let pending: {file: File; options: string; uploadKey: string; jobKey: string; attachmentId?: string} | null = null
|
||||
return {
|
||||
reset() { pending = null },
|
||||
async submit(file: File, options: Record<string, unknown>) {
|
||||
const serialized = JSON.stringify(options)
|
||||
if (!pending || pending.file !== file || pending.options !== serialized) {
|
||||
pending = {file, options: serialized, uploadKey: crypto.randomUUID(), jobKey: crypto.randomUUID()}
|
||||
}
|
||||
const current = pending
|
||||
if (!current.attachmentId) current.attachmentId = (await mediaService.upload(file, current.uploadKey)).attachment_id
|
||||
return mediaService.create({...JSON.parse(current.options), attachment_id: current.attachmentId, idempotency_key: current.jobKey})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -209,7 +209,6 @@ export async function installTheme(
|
||||
if (warnings.length > 0) {
|
||||
console.warn('[theme] CSS validation warnings:', warnings)
|
||||
}
|
||||
applyThemeCss(manifest.theme_id, cssContent)
|
||||
const installed: InstalledTheme = {
|
||||
theme_id: manifest.theme_id,
|
||||
name: manifest.name,
|
||||
@@ -233,14 +232,7 @@ export async function installTheme(
|
||||
}
|
||||
|
||||
export async function listInstalledThemes(): Promise<InstalledTheme[]> {
|
||||
const themes = loadStoredThemes()
|
||||
for (const theme of themes) {
|
||||
if (!theme.builtin) {
|
||||
const css = localStorage.getItem(`${STORAGE_KEY}-css-${theme.theme_id}`)
|
||||
if (css) applyThemeCss(theme.theme_id, css)
|
||||
}
|
||||
}
|
||||
return themes
|
||||
return loadStoredThemes()
|
||||
}
|
||||
|
||||
export async function enableTheme(themeId: string): Promise<InstalledTheme> {
|
||||
@@ -279,6 +271,11 @@ export function getActiveCustomTheme(): string | null {
|
||||
}
|
||||
|
||||
export function setActiveCustomTheme(themeId: string | null) {
|
||||
const css = themeId ? localStorage.getItem(`${STORAGE_KEY}-css-${themeId}`) : null
|
||||
// Validate before changing the current page. Only the selected theme owns a style node.
|
||||
if (css) validateCssSafety(css)
|
||||
document.head.querySelectorAll('style[id^="theme-style-"]').forEach(style => style.remove())
|
||||
if (themeId && css) applyThemeCss(themeId, css)
|
||||
if (themeId) localStorage.setItem(ACTIVE_CUSTOM_KEY, themeId)
|
||||
else localStorage.removeItem(ACTIVE_CUSTOM_KEY)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
OperationResponse,
|
||||
} from '@/contracts'
|
||||
import apiClient from './apiClient'
|
||||
import { t } from '@/i18n'
|
||||
import * as noteService from './noteService'
|
||||
|
||||
/** Web 联调只连接 AI Core 配置的单一 Vault;多 Vault 选择由 Tauri Host 接管。 */
|
||||
@@ -72,7 +73,7 @@ async function requireNoteId(filePath: string): Promise<string> {
|
||||
await refreshTree()
|
||||
noteId = noteIdByPath.get(path)
|
||||
}
|
||||
if (!noteId) throw new Error(`笔记尚未建立后端索引:${path}`)
|
||||
if (!noteId) throw new Error(`${t('笔记尚未建立后端索引:', 'The note has not been indexed by the backend: ')}${path}`)
|
||||
return noteId
|
||||
}
|
||||
|
||||
@@ -174,7 +175,7 @@ export async function deleteFile(pathValue: string): Promise<void> {
|
||||
export async function moveFile(sourcePath: string, targetPath: string): Promise<void> {
|
||||
const source = normalizePublicPath(sourcePath)
|
||||
if (typeByPath.get(source) !== 'file') {
|
||||
throw new Error('当前阶段只支持移动笔记文件。')
|
||||
throw new Error(t('当前阶段只支持移动笔记文件。', 'Only note files can be moved at this stage.'))
|
||||
}
|
||||
await noteService.moveNote(await requireNoteId(source), relativePath(targetPath))
|
||||
await refreshTree()
|
||||
|
||||
Reference in New Issue
Block a user