M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。 M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变); 网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则; 前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。 K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径; coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。 本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400); GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化; 字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。 门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
111 lines
3.6 KiB
TypeScript
111 lines
3.6 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useState, type ChangeEvent } from "react";
|
||
|
||
import { hasUsableSamples, type StyleLearnMode } from "@/lib/style/style";
|
||
import { useToast } from "@/components/Toast";
|
||
|
||
interface StyleUploadProps {
|
||
busy: boolean;
|
||
pollStatus: "idle" | "polling" | "done" | "error";
|
||
progress: number;
|
||
// 是否已有指纹(决定默认 mode = update)。
|
||
hasFingerprint: boolean;
|
||
onLearn: (samples: string[], mode: StyleLearnMode) => void;
|
||
}
|
||
|
||
// 学文风输入(UX §6.9):多段文本粘贴 + 文件选择(在浏览器读成文本)。
|
||
// 样本以正文文本入 body(无对象存储,确认决策)。
|
||
export function StyleUpload({
|
||
busy,
|
||
pollStatus,
|
||
progress,
|
||
hasFingerprint,
|
||
onLearn,
|
||
}: StyleUploadProps) {
|
||
const [text, setText] = useState("");
|
||
const toast = useToast();
|
||
|
||
// 读取选中的文本文件,追加到文本框(多文件用空行分隔)。
|
||
const onFiles = useCallback(
|
||
async (e: ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||
const files = Array.from(e.target.files ?? []);
|
||
if (files.length === 0) return;
|
||
try {
|
||
const contents = await Promise.all(files.map((f) => f.text()));
|
||
setText((prev) => {
|
||
const joined = contents.join("\n\n");
|
||
return prev.trim().length > 0 ? `${prev}\n\n${joined}` : joined;
|
||
});
|
||
} catch {
|
||
toast("读取文件失败,请改用粘贴。", "error");
|
||
} finally {
|
||
e.target.value = ""; // 允许重复选同一文件。
|
||
}
|
||
},
|
||
[toast],
|
||
);
|
||
|
||
const submit = (): void => {
|
||
// 以空行切分成多段样本(对齐后端 samples:list[str])。
|
||
const samples = text
|
||
.split(/\n{2,}/)
|
||
.map((s) => s.trim())
|
||
.filter((s) => s.length > 0);
|
||
if (!hasUsableSamples(samples)) {
|
||
toast("请粘贴或选择至少一段样本正文。", "error");
|
||
return;
|
||
}
|
||
onLearn(samples, hasFingerprint ? "update" : "create");
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label
|
||
htmlFor="style-samples"
|
||
className="mb-1 block text-sm font-semibold text-ink"
|
||
>
|
||
样本正文(空行分段;可粘贴多段)
|
||
</label>
|
||
<textarea
|
||
id="style-samples"
|
||
value={text}
|
||
onChange={(e) => setText(e.target.value)}
|
||
placeholder="粘贴你想学习文风的章节正文…"
|
||
className="min-h-[30vh] w-full resize-y rounded border border-line bg-panel p-3 font-serif text-[15px] leading-[1.9] text-ink focus:border-cinnabar focus:outline-none"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<label className="cursor-pointer rounded border border-line px-3 py-1.5 text-sm text-ink hover:border-cinnabar hover:text-cinnabar">
|
||
选择文本文件
|
||
<input
|
||
type="file"
|
||
accept=".txt,.md,text/plain,text/markdown"
|
||
multiple
|
||
onChange={(e) => void onFiles(e)}
|
||
className="sr-only"
|
||
/>
|
||
</label>
|
||
<button
|
||
type="button"
|
||
onClick={submit}
|
||
disabled={busy}
|
||
className="rounded bg-cinnabar px-4 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-50"
|
||
>
|
||
{hasFingerprint ? "重新学习文风" : "学习文风"}
|
||
</button>
|
||
</div>
|
||
|
||
{pollStatus === "polling" ? (
|
||
<p className="text-xs text-info" aria-live="polite">
|
||
提取文风指纹中…({progress}%)
|
||
</p>
|
||
) : pollStatus === "error" ? (
|
||
<p className="text-xs text-conflict">学文风失败,请稍后重试。</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|