优化移动端智能标签入口
This commit is contained in:
1 parent
090b078b31
commit
f7cb16b703
5 files changed
+240
-427
No files matched your search
@@ -14,6 +14,7 @@ enum CreateMemoChrome {
|
||||
static let metaRow = "createMemoMetaRow"
|
||||
static let notebook = "createMemoNotebookButton"
|
||||
static let tags = "createMemoTagsInput"
|
||||
static let smartTags = "createMemoSmartTagsButton"
|
||||
static let editorFrame = "createMemoEditorFrame"
|
||||
static let imageTool = "createMemoImageTool"
|
||||
}
|
||||
|
||||
@@ -51,6 +51,12 @@ struct MemoEditView: View {
|
||||
@State private var showEmptyAiSelectionAlert = false
|
||||
@State private var aiUndoToken: UUID?
|
||||
@State private var didImportSharedImages = false
|
||||
@State private var isSuggestingTags = false
|
||||
@State private var smartTagsAdded = false
|
||||
@State private var showSmartTagAlert = false
|
||||
@State private var smartTagAlertTitle = ""
|
||||
@State private var smartTagAlertMessage = ""
|
||||
@State private var smartTagTask: Task<Void, Never>?
|
||||
|
||||
private var title: String { get { viewModel.title } nonmutating set { viewModel.title = newValue } }
|
||||
private var tagsText: String { get { viewModel.tagsText } nonmutating set { viewModel.tagsText = newValue } }
|
||||
@@ -148,9 +154,7 @@ struct MemoEditView: View {
|
||||
}
|
||||
.sheet(isPresented: $showTagPicker) {
|
||||
MemoTagPickerSheet(
|
||||
selectedTags: viewModel.tags,
|
||||
title: title,
|
||||
contentMarkdown: contentMarkdown
|
||||
selectedTags: viewModel.tags
|
||||
) { tags in
|
||||
tagsText = tags.joined(separator: ", ")
|
||||
markDirtyAndScheduleSave()
|
||||
@@ -198,6 +202,11 @@ struct MemoEditView: View {
|
||||
en: "Select some text in the note body, then tap AI again."
|
||||
))
|
||||
}
|
||||
.alert(smartTagAlertTitle, isPresented: $showSmartTagAlert) {
|
||||
Button(env.preferences.t("好的", en: "OK"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(smartTagAlertMessage)
|
||||
}
|
||||
.alert(
|
||||
env.preferences.t("应用模板?", en: "Apply template?"),
|
||||
isPresented: $showApplyTemplateConfirm
|
||||
@@ -291,6 +300,8 @@ struct MemoEditView: View {
|
||||
await importInitialSharedImagesIfNeeded()
|
||||
}
|
||||
.onDisappear {
|
||||
smartTagTask?.cancel()
|
||||
smartTagTask = nil
|
||||
viewModel.cancelScheduledSave()
|
||||
// Create commit is owned by Back / Done (Android `requestClose` = createMutation).
|
||||
// Only flush edit sessions, or create-after-image-materialize if still dirty and
|
||||
@@ -476,6 +487,35 @@ struct MemoEditView: View {
|
||||
.accessibilityLabel(env.preferences.t("笔记标签", en: "Tags"))
|
||||
.accessibilityIdentifier(CreateMemoChrome.tags)
|
||||
|
||||
Button {
|
||||
generateAndApplySmartTags()
|
||||
} label: {
|
||||
Group {
|
||||
if isSuggestingTags {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(AppTheme.accentStrong)
|
||||
} else {
|
||||
Image(systemName: smartTagsAdded ? "checkmark" : "sparkles")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.accentStrong)
|
||||
}
|
||||
}
|
||||
.frame(width: 34, height: 34)
|
||||
.background(smartTagsAdded ? AppTheme.accentSoft : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isSuggestingTags || busyChrome || viewModel.tags.count >= 24
|
||||
|| (title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& contentMarkdown.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty))
|
||||
.opacity((busyChrome || viewModel.tags.count >= 24) ? 0.45 : 1)
|
||||
.accessibilityLabel(env.preferences.t(
|
||||
isSuggestingTags ? "正在生成智能标签" : smartTagsAdded ? "智能标签已添加" : "智能标签",
|
||||
en: isSuggestingTags ? "Generating smart tags" : smartTagsAdded ? "Smart tags added" : "Smart tags"
|
||||
))
|
||||
.accessibilityIdentifier(CreateMemoChrome.smartTags)
|
||||
|
||||
}
|
||||
.frame(minHeight: 40)
|
||||
.accessibilityIdentifier(CreateMemoChrome.metaRow)
|
||||
@@ -702,6 +742,70 @@ struct MemoEditView: View {
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
private func generateAndApplySmartTags() {
|
||||
guard !isSuggestingTags, !busyChrome, viewModel.tags.count < 24 else { return }
|
||||
smartTagTask?.cancel()
|
||||
isSuggestingTags = true
|
||||
smartTagsAdded = false
|
||||
smartTagTask = Task { @MainActor in
|
||||
await pullEditorSnapshotIfPossible()
|
||||
let currentTags = viewModel.tags
|
||||
let input = AiTagSuggestionsInput(
|
||||
title: title,
|
||||
contentMarkdown: contentMarkdown,
|
||||
currentTags: currentTags,
|
||||
locale: env.preferences.isEnglish ? "en-US" : "zh-CN"
|
||||
)
|
||||
do {
|
||||
let response = try await env.session.client.suggestAiTags(input)
|
||||
try Task.checkCancellation()
|
||||
let availableSlots = max(0, 24 - currentTags.count)
|
||||
let additions = Array(response.suggestions
|
||||
.map(\.name)
|
||||
.filter { name in
|
||||
!currentTags.contains { $0.caseInsensitiveCompare(name) == .orderedSame }
|
||||
}
|
||||
.prefix(availableSlots))
|
||||
guard !additions.isEmpty else {
|
||||
isSuggestingTags = false
|
||||
smartTagAlertTitle = env.preferences.t("智能标签", en: "Smart tags")
|
||||
smartTagAlertMessage = env.preferences.t(
|
||||
"没有找到适合这篇笔记的新标签。",
|
||||
en: "No useful new tags were found for this note."
|
||||
)
|
||||
showSmartTagAlert = true
|
||||
smartTagTask = nil
|
||||
return
|
||||
}
|
||||
tagsText = (currentTags + additions).joined(separator: ", ")
|
||||
markDirtyAndScheduleSave()
|
||||
isSuggestingTags = false
|
||||
smartTagsAdded = true
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
smartTagsAdded = false
|
||||
smartTagTask = nil
|
||||
} catch is CancellationError {
|
||||
isSuggestingTags = false
|
||||
} catch let apiError as APIError where apiError.code == "ai_not_configured" {
|
||||
isSuggestingTags = false
|
||||
smartTagAlertTitle = env.preferences.t("智能标签生成失败", en: "Couldn't generate smart tags")
|
||||
smartTagAlertMessage = env.preferences.t(
|
||||
"请先在“AI 集成”中配置默认模型。",
|
||||
en: "Configure a model in AI Integrations first."
|
||||
)
|
||||
showSmartTagAlert = true
|
||||
smartTagTask = nil
|
||||
} catch {
|
||||
isSuggestingTags = false
|
||||
smartTagAlertTitle = env.preferences.t("智能标签生成失败", en: "Couldn't generate smart tags")
|
||||
smartTagAlertMessage = error.localizedDescription
|
||||
showSmartTagAlert = true
|
||||
smartTagTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func requestApplyTemplate(_ seed: CreateMemoSeed) {
|
||||
let current = CreateMemoSeed(title: title, contentMarkdown: contentMarkdown, tagsText: tagsText)
|
||||
if current.hasContent {
|
||||
@@ -1213,24 +1317,13 @@ private struct MemoTagPickerSheet: View {
|
||||
@State private var query = ""
|
||||
@State private var availableTags: [TagSummary] = []
|
||||
@State private var error: String?
|
||||
@State private var aiSuggestions: [AiTagSuggestion]?
|
||||
@State private var selectedAiSuggestions = Set<String>()
|
||||
@State private var aiError: String?
|
||||
@State private var isSuggesting = false
|
||||
@State private var suggestionTask: Task<Void, Never>?
|
||||
let title: String
|
||||
let contentMarkdown: String
|
||||
let onChange: ([String]) -> Void
|
||||
|
||||
init(
|
||||
selectedTags: [String],
|
||||
title: String,
|
||||
contentMarkdown: String,
|
||||
onChange: @escaping ([String]) -> Void
|
||||
) {
|
||||
_selection = State(initialValue: selectedTags)
|
||||
self.title = title
|
||||
self.contentMarkdown = contentMarkdown
|
||||
self.onChange = onChange
|
||||
}
|
||||
|
||||
@@ -1295,8 +1388,6 @@ private struct MemoTagPickerSheet: View {
|
||||
.overlay(RoundedRectangle(cornerRadius: 9).stroke(AppTheme.border, lineWidth: 1))
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
aiSuggestionCard
|
||||
|
||||
if let error {
|
||||
Text(error).font(.system(size: 13)).foregroundStyle(AppTheme.danger)
|
||||
}
|
||||
@@ -1337,111 +1428,9 @@ private struct MemoTagPickerSheet: View {
|
||||
}
|
||||
}
|
||||
.task { loadTags() }
|
||||
.onDisappear {
|
||||
suggestionTask?.cancel()
|
||||
suggestionTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var aiSuggestionCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Label(env.preferences.t("AI 推荐标签", en: "AI tag suggestions"), systemImage: "sparkles")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(AppTheme.accentText)
|
||||
Text(env.preferences.t(
|
||||
"确认后再添加到当前笔记;合适时会优先复用已有标签。",
|
||||
en: "Review suggestions before adding them. Existing tags are preferred when they fit."
|
||||
))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(AppTheme.accentStrong)
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
Button {
|
||||
requestAiSuggestions()
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
if isSuggesting {
|
||||
ProgressView().tint(.white).controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: "sparkles")
|
||||
}
|
||||
Text(env.preferences.t(
|
||||
aiSuggestions == nil ? "生成建议" : "重新生成",
|
||||
en: aiSuggestions == nil ? "Suggest" : "Suggest again"
|
||||
))
|
||||
}
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(minHeight: 34)
|
||||
.background(AppTheme.accentAction)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isSuggesting || (title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& contentMarkdown.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty))
|
||||
.opacity(isSuggesting ? 0.6 : 1)
|
||||
}
|
||||
|
||||
if let aiError {
|
||||
Text(aiError).font(.system(size: 12, weight: .semibold)).foregroundStyle(AppTheme.danger)
|
||||
}
|
||||
|
||||
if let aiSuggestions {
|
||||
if aiSuggestions.isEmpty {
|
||||
Text(env.preferences.t("没有找到适合这篇笔记的标签。", en: "No useful tags were found for this note."))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(AppTheme.accentStrong)
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 7) {
|
||||
ForEach(aiSuggestions) { suggestion in
|
||||
let alreadySelected = containsTag(suggestion.name, in: selection)
|
||||
let chosen = alreadySelected || selectedAiSuggestions.contains(suggestion.name)
|
||||
Button {
|
||||
toggleAiSuggestion(suggestion.name)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
if chosen { Image(systemName: "checkmark") }
|
||||
Text("#\(suggestion.name) · \(suggestionLabel(suggestion, alreadySelected: alreadySelected))")
|
||||
}
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(alreadySelected ? AppTheme.accentStrong : chosen ? Color.white : AppTheme.accentText)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(minHeight: 30)
|
||||
.background(alreadySelected ? AppTheme.accentSoft : chosen ? AppTheme.accentAction : AppTheme.card)
|
||||
.clipShape(Capsule())
|
||||
.overlay(Capsule().stroke(AppTheme.accentBorder, lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(alreadySelected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(env.preferences.t(
|
||||
"添加选中标签(\(selectedAiSuggestions.count))",
|
||||
en: "Add selected (\(selectedAiSuggestions.count))"
|
||||
)) {
|
||||
applyAiSuggestions()
|
||||
}
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(AppTheme.title)
|
||||
.disabled(selectedAiSuggestions.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(AppTheme.accentSoft)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(AppTheme.accentBorder, lineWidth: 1))
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private func loadTags() {
|
||||
guard let scope = env.session.dataScope else { return }
|
||||
do {
|
||||
@@ -1472,76 +1461,6 @@ private struct MemoTagPickerSheet: View {
|
||||
onChange(selection)
|
||||
}
|
||||
|
||||
private func requestAiSuggestions() {
|
||||
suggestionTask?.cancel()
|
||||
isSuggesting = true
|
||||
aiError = nil
|
||||
let input = AiTagSuggestionsInput(
|
||||
title: title,
|
||||
contentMarkdown: contentMarkdown,
|
||||
currentTags: selection,
|
||||
locale: env.preferences.isEnglish ? "en-US" : "zh-CN"
|
||||
)
|
||||
suggestionTask = Task { @MainActor in
|
||||
do {
|
||||
let response = try await env.session.client.suggestAiTags(input)
|
||||
try Task.checkCancellation()
|
||||
aiSuggestions = response.suggestions
|
||||
let availableSlots = max(0, 24 - selection.count)
|
||||
selectedAiSuggestions = Set(
|
||||
response.suggestions
|
||||
.filter { !containsTag($0.name, in: selection) }
|
||||
.prefix(availableSlots)
|
||||
.map(\.name)
|
||||
)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch let apiError as APIError where apiError.code == "ai_not_configured" {
|
||||
aiSuggestions = nil
|
||||
selectedAiSuggestions = []
|
||||
aiError = env.preferences.t(
|
||||
"请先在“AI 集成”中配置默认模型。",
|
||||
en: "Configure a model in AI Integrations first."
|
||||
)
|
||||
} catch {
|
||||
aiSuggestions = nil
|
||||
selectedAiSuggestions = []
|
||||
aiError = error.localizedDescription
|
||||
}
|
||||
isSuggesting = false
|
||||
suggestionTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleAiSuggestion(_ name: String) {
|
||||
if selectedAiSuggestions.contains(name) {
|
||||
selectedAiSuggestions.remove(name)
|
||||
} else if selection.count + selectedAiSuggestions.count < 24 {
|
||||
selectedAiSuggestions.insert(name)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyAiSuggestions() {
|
||||
for suggestion in aiSuggestions ?? []
|
||||
where selectedAiSuggestions.contains(suggestion.name)
|
||||
&& selection.count < 24
|
||||
&& !containsTag(suggestion.name, in: selection) {
|
||||
selection.append(suggestion.name)
|
||||
}
|
||||
selectedAiSuggestions = []
|
||||
onChange(selection)
|
||||
}
|
||||
|
||||
private func containsTag(_ name: String, in tags: [String]) -> Bool {
|
||||
tags.contains { $0.caseInsensitiveCompare(name) == .orderedSame }
|
||||
}
|
||||
|
||||
private func suggestionLabel(_ suggestion: AiTagSuggestion, alreadySelected: Bool) -> String {
|
||||
if alreadySelected { return env.preferences.t("已添加", en: "Added") }
|
||||
return suggestion.existing
|
||||
? env.preferences.t("已有", en: "Existing")
|
||||
: env.preferences.t("新建", en: "New")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -100,6 +100,9 @@ final class ChromeParityTests: XCTestCase {
|
||||
XCTAssertTrue(src.contains("CreateMemoChrome.metaRow"), "missing meta row")
|
||||
XCTAssertTrue(src.contains("CreateMemoChrome.notebook"), "missing notebook control")
|
||||
XCTAssertTrue(src.contains("CreateMemoChrome.tags"), "missing tags field")
|
||||
XCTAssertTrue(src.contains("CreateMemoChrome.smartTags"), "missing one-tap smart tags control")
|
||||
XCTAssertTrue(src.contains("generateAndApplySmartTags"), "smart tags must generate and apply in one action")
|
||||
XCTAssertFalse(src.contains("AI 推荐标签"), "tag picker must not retain the multi-step AI suggestion flow")
|
||||
XCTAssertTrue(src.contains("CreateMemoChrome.editorFrame"), "missing bordered editor")
|
||||
XCTAssertTrue(src.contains("TipTapWebView"), "editor wiring must remain")
|
||||
XCTAssertTrue(src.contains("commitCreate") || src.contains("persistDraftOrQueue"), "save path must remain")
|
||||
|
||||
@@ -1789,33 +1789,118 @@ const NotebookPickerModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
const TagPickerModal = ({
|
||||
const SmartTagButton = ({
|
||||
client,
|
||||
contentMarkdown,
|
||||
disabled = false,
|
||||
onChange,
|
||||
selectedTags,
|
||||
title,
|
||||
}: {
|
||||
client: ReturnType<typeof useSession>["client"];
|
||||
contentMarkdown: string;
|
||||
disabled?: boolean;
|
||||
onChange: (tags: string[]) => void;
|
||||
selectedTags: string[];
|
||||
title: string;
|
||||
}) => {
|
||||
const { resolvedLocale, translate } = useMobileLocale();
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "success">("idle");
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
const feedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const unavailable = disabled || !client || selectedTags.length >= 24 || (!title.trim() && !contentMarkdown.trim());
|
||||
|
||||
useEffect(() => () => {
|
||||
controllerRef.current?.abort();
|
||||
if (feedbackTimerRef.current) clearTimeout(feedbackTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const generateAndApplyTags = async () => {
|
||||
if (unavailable || !client) return;
|
||||
controllerRef.current?.abort();
|
||||
if (feedbackTimerRef.current) clearTimeout(feedbackTimerRef.current);
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
setStatus("loading");
|
||||
try {
|
||||
const selectedTagKeys = new Set(selectedTags.map((tag) => tag.toLocaleLowerCase()));
|
||||
const result = await client.suggestAiTags({
|
||||
title,
|
||||
contentMarkdown,
|
||||
currentTags: selectedTags,
|
||||
locale: resolvedLocale,
|
||||
}, controller.signal);
|
||||
const additions = result.suggestions
|
||||
.filter((suggestion) => !selectedTagKeys.has(suggestion.name.toLocaleLowerCase()))
|
||||
.slice(0, Math.max(0, 24 - selectedTags.length))
|
||||
.map((suggestion) => suggestion.name);
|
||||
if (additions.length === 0) {
|
||||
setStatus("idle");
|
||||
Alert.alert(translate("智能标签"), translate("没有找到适合这篇笔记的新标签。"));
|
||||
return;
|
||||
}
|
||||
onChange(Array.from(new Set([...selectedTags, ...additions])).slice(0, 24));
|
||||
setStatus("success");
|
||||
feedbackTimerRef.current = setTimeout(() => {
|
||||
setStatus("idle");
|
||||
feedbackTimerRef.current = null;
|
||||
}, 4000);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
setStatus("idle");
|
||||
Alert.alert(
|
||||
translate("智能标签生成失败"),
|
||||
error instanceof ApiRequestError && error.code === "ai_not_configured"
|
||||
? translate("请先在“AI 集成”中配置默认模型。")
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: translate("AI 标签建议生成失败。")
|
||||
);
|
||||
} finally {
|
||||
if (controllerRef.current === controller) controllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const accessibilityLabel = status === "loading"
|
||||
? translate("正在生成智能标签")
|
||||
: status === "success"
|
||||
? translate("智能标签已添加")
|
||||
: translate("智能标签");
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled: unavailable || status === "loading" }}
|
||||
disabled={unavailable || status === "loading"}
|
||||
onPress={() => void generateAndApplyTags()}
|
||||
style={[styles.smartTagButton, status === "success" && styles.smartTagButtonSuccess, unavailable && styles.buttonDisabled]}
|
||||
>
|
||||
{status === "loading"
|
||||
? <ActivityIndicator color="#047857" size="small" />
|
||||
: status === "success"
|
||||
? <Check color="#047857" size={17} />
|
||||
: <Sparkles color="#047857" size={17} />}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const TagPickerModal = ({
|
||||
dataScope,
|
||||
onChange,
|
||||
onClose,
|
||||
selectedTags,
|
||||
title,
|
||||
visible,
|
||||
}: {
|
||||
client: ReturnType<typeof useSession>["client"];
|
||||
contentMarkdown: string;
|
||||
dataScope: string;
|
||||
onChange: (tags: string[]) => void;
|
||||
onClose: () => void;
|
||||
selectedTags: string[];
|
||||
title: string;
|
||||
visible: boolean;
|
||||
}) => {
|
||||
const { resolvedLocale, translate } = useMobileLocale();
|
||||
const { translate } = useMobileLocale();
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [aiSuggestions, setAiSuggestions] = useState<Awaited<ReturnType<NonNullable<typeof client>["suggestAiTags"]>>["suggestions"] | null>(null);
|
||||
const [selectedAiSuggestions, setSelectedAiSuggestions] = useState<string[]>([]);
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const aiControllerRef = useRef<AbortController | null>(null);
|
||||
const tagsQuery = useQuery({
|
||||
queryKey: ["mobile-tags", dataScope],
|
||||
queryFn: () => listLocalTags(dataScope),
|
||||
@@ -1829,18 +1914,9 @@ const TagPickerModal = ({
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSearchText("");
|
||||
setAiSuggestions(null);
|
||||
setSelectedAiSuggestions([]);
|
||||
setAiError(null);
|
||||
return;
|
||||
}
|
||||
aiControllerRef.current?.abort();
|
||||
aiControllerRef.current = null;
|
||||
setAiLoading(false);
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => () => aiControllerRef.current?.abort(), []);
|
||||
|
||||
const commit = (nextTags: string[]) => onChange(Array.from(new Set(nextTags)).slice(0, 24));
|
||||
const toggleTag = (name: string) => commit(
|
||||
selectedTags.includes(name) ? selectedTags.filter((tag) => tag !== name) : [...selectedTags, name]
|
||||
@@ -1851,53 +1927,6 @@ const TagPickerModal = ({
|
||||
commit([...selectedTags, ...additions]);
|
||||
setSearchText("");
|
||||
};
|
||||
const selectedTagKeys = new Set(selectedTags.map((tag) => tag.toLocaleLowerCase()));
|
||||
const requestAiSuggestions = async () => {
|
||||
if (!client || (!title.trim() && !contentMarkdown.trim())) return;
|
||||
aiControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
aiControllerRef.current = controller;
|
||||
setAiLoading(true);
|
||||
setAiError(null);
|
||||
try {
|
||||
const result = await client.suggestAiTags({
|
||||
title,
|
||||
contentMarkdown,
|
||||
currentTags: selectedTags,
|
||||
locale: resolvedLocale,
|
||||
}, controller.signal);
|
||||
setAiSuggestions(result.suggestions);
|
||||
setSelectedAiSuggestions(
|
||||
result.suggestions
|
||||
.filter((suggestion) => !selectedTagKeys.has(suggestion.name.toLocaleLowerCase()))
|
||||
.slice(0, Math.max(0, 24 - selectedTags.length))
|
||||
.map((suggestion) => suggestion.name)
|
||||
);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return;
|
||||
setAiSuggestions(null);
|
||||
setSelectedAiSuggestions([]);
|
||||
setAiError(
|
||||
error instanceof ApiRequestError && error.code === "ai_not_configured"
|
||||
? translate("请先在“AI 集成”中配置默认模型。")
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: translate("AI 标签建议生成失败。")
|
||||
);
|
||||
} finally {
|
||||
if (aiControllerRef.current === controller) {
|
||||
aiControllerRef.current = null;
|
||||
setAiLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
const toggleAiSuggestion = (name: string) => setSelectedAiSuggestions((current) =>
|
||||
current.includes(name) ? current.filter((tag) => tag !== name) : [...current, name]
|
||||
);
|
||||
const applyAiSuggestions = () => {
|
||||
commit([...selectedTags, ...selectedAiSuggestions]);
|
||||
setSelectedAiSuggestions([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal animationType="fade" onRequestClose={onClose} transparent visible={visible}>
|
||||
@@ -1947,67 +1976,6 @@ const TagPickerModal = ({
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View accessibilityLabel={translate("AI 推荐标签")} style={styles.tagPickerAiCard}>
|
||||
<View style={styles.tagPickerAiHeader}>
|
||||
<View style={styles.tagPickerAiHeaderText}>
|
||||
<View style={styles.tagPickerAiTitleRow}>
|
||||
<Sparkles color="#047857" size={17} />
|
||||
<Text style={styles.tagPickerAiTitle}>{translate("AI 推荐标签")}</Text>
|
||||
</View>
|
||||
<Text style={styles.tagPickerAiDescription}>{translate("确认后再添加到当前笔记;合适时会优先复用已有标签。")}</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
accessibilityLabel={translate(aiSuggestions ? "重新生成" : "生成建议")}
|
||||
accessibilityRole="button"
|
||||
disabled={aiLoading || !client || (!title.trim() && !contentMarkdown.trim())}
|
||||
onPress={() => void requestAiSuggestions()}
|
||||
style={[styles.tagPickerAiGenerateButton, (aiLoading || !client || (!title.trim() && !contentMarkdown.trim())) && styles.buttonDisabled]}
|
||||
>
|
||||
{aiLoading ? <ActivityIndicator color="#ffffff" size="small" /> : <Sparkles color="#ffffff" size={15} />}
|
||||
<Text style={styles.tagPickerAiGenerateText}>{translate(aiSuggestions ? "重新生成" : "生成建议")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{aiError ? <Text style={styles.tagPickerAiError}>{aiError}</Text> : null}
|
||||
{aiSuggestions ? (
|
||||
aiSuggestions.length > 0 ? (
|
||||
<>
|
||||
<View style={styles.tagPickerAiSuggestions}>
|
||||
{aiSuggestions.map((suggestion) => {
|
||||
const alreadySelected = selectedTagKeys.has(suggestion.name.toLocaleLowerCase());
|
||||
const chosen = alreadySelected || selectedAiSuggestions.includes(suggestion.name);
|
||||
return (
|
||||
<Pressable
|
||||
key={suggestion.name}
|
||||
accessibilityLabel={`#${suggestion.name}`}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled: alreadySelected, selected: chosen }}
|
||||
disabled={alreadySelected}
|
||||
onPress={() => toggleAiSuggestion(suggestion.name)}
|
||||
style={[styles.tagPickerAiSuggestion, chosen && styles.tagPickerAiSuggestionSelected, alreadySelected && styles.tagPickerAiSuggestionAdded]}
|
||||
>
|
||||
{chosen ? <Check color={alreadySelected ? "#047857" : "#ffffff"} size={13} /> : null}
|
||||
<Text style={[styles.tagPickerAiSuggestionText, chosen && styles.tagPickerAiSuggestionTextSelected, alreadySelected && styles.tagPickerAiSuggestionTextAdded]}>
|
||||
#{suggestion.name} · {translate(alreadySelected ? "已添加" : suggestion.existing ? "已有" : "新建")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<Pressable
|
||||
accessibilityLabel={translate(`添加选中标签(${selectedAiSuggestions.length})`)}
|
||||
accessibilityRole="button"
|
||||
disabled={selectedAiSuggestions.length === 0}
|
||||
onPress={applyAiSuggestions}
|
||||
style={[styles.tagPickerAiApplyButton, selectedAiSuggestions.length === 0 && styles.buttonDisabled]}
|
||||
>
|
||||
<Text style={styles.tagPickerAiApplyText}>{translate(`添加选中标签(${selectedAiSuggestions.length})`)}</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : <Text style={styles.tagPickerAiDescription}>{translate("没有找到适合这篇笔记的标签。")}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{tagsQuery.isLoading ? (
|
||||
<ActivityIndicator color="#16a06e" style={styles.tagPickerLoading} />
|
||||
) : visibleTags.length === 0 ? (
|
||||
@@ -2610,6 +2578,16 @@ const CreateMemoModal = ({
|
||||
</Text>
|
||||
<ChevronDown color="#94a3b8" size={14} />
|
||||
</Pressable>
|
||||
<SmartTagButton
|
||||
client={client}
|
||||
contentMarkdown={contentMarkdown}
|
||||
onChange={(nextTags) => {
|
||||
setTagsText(nextTags.join(", "));
|
||||
markDirty();
|
||||
}}
|
||||
selectedTags={parseTags(tagsText)}
|
||||
title={title}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.createMemoEditorFrame}>
|
||||
@@ -2632,8 +2610,6 @@ const CreateMemoModal = ({
|
||||
visible={notebookPickerOpen}
|
||||
/>
|
||||
<TagPickerModal
|
||||
client={client}
|
||||
contentMarkdown={contentMarkdown}
|
||||
dataScope={dataScope}
|
||||
onChange={(nextTags) => {
|
||||
setTagsText(nextTags.join(", "));
|
||||
@@ -2641,7 +2617,6 @@ const CreateMemoModal = ({
|
||||
}}
|
||||
onClose={() => setTagPickerOpen(false)}
|
||||
selectedTags={parseTags(tagsText)}
|
||||
title={title}
|
||||
visible={tagPickerOpen}
|
||||
/>
|
||||
<MobileResourceActions
|
||||
@@ -3186,6 +3161,18 @@ const RichEditorModal = ({
|
||||
</Text>
|
||||
<ChevronDown color="#94a3b8" size={14} />
|
||||
</Pressable>
|
||||
<SmartTagButton
|
||||
client={client}
|
||||
contentMarkdown={contentMarkdownRef.current}
|
||||
disabled={saving || uploading}
|
||||
onChange={(nextTags) => {
|
||||
setTagsText(nextTags.join(", "));
|
||||
dirtyRef.current = true;
|
||||
setDirty(true);
|
||||
}}
|
||||
selectedTags={parseTags(tagsText)}
|
||||
title={title}
|
||||
/>
|
||||
</View>
|
||||
{draftRestored ? <Text style={styles.richEditorDraftNotice}>已恢复上次未完成的本地草稿</Text> : null}
|
||||
<View style={styles.richEditorFrame}>
|
||||
@@ -3212,8 +3199,6 @@ const RichEditorModal = ({
|
||||
visible={notebookPickerOpen}
|
||||
/>
|
||||
<TagPickerModal
|
||||
client={client}
|
||||
contentMarkdown={contentMarkdownRef.current}
|
||||
dataScope={createMobileDataScope(session?.baseUrl ?? baseUrl, session?.user?.id)}
|
||||
onChange={(nextTags) => {
|
||||
setTagsText(nextTags.join(", "));
|
||||
@@ -3222,7 +3207,6 @@ const RichEditorModal = ({
|
||||
}}
|
||||
onClose={() => setTagPickerOpen(false)}
|
||||
selectedTags={parseTags(tagsText)}
|
||||
title={title}
|
||||
visible={tagPickerOpen}
|
||||
/>
|
||||
<MobileResourceActions
|
||||
|
||||
@@ -1211,8 +1211,7 @@ const baseWorkspaceStyles = StyleSheet.create({
|
||||
color: "#64748b",
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
lineHeight: 23,
|
||||
minHeight: 36,
|
||||
lineHeight: 20,
|
||||
minWidth: 0,
|
||||
padding: 0,
|
||||
},
|
||||
@@ -1227,6 +1226,16 @@ const baseWorkspaceStyles = StyleSheet.create({
|
||||
createMemoTagsPlaceholder: {
|
||||
color: "#94a3b8",
|
||||
},
|
||||
smartTagButton: {
|
||||
alignItems: "center",
|
||||
borderRadius: 8,
|
||||
height: 34,
|
||||
justifyContent: "center",
|
||||
width: 34,
|
||||
},
|
||||
smartTagButtonSuccess: {
|
||||
backgroundColor: "#ecfdf5",
|
||||
},
|
||||
createMemoToolButton: {
|
||||
alignItems: "center",
|
||||
borderColor: "transparent",
|
||||
@@ -1563,109 +1572,6 @@ const baseWorkspaceStyles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
fontWeight: "800",
|
||||
},
|
||||
tagPickerAiCard: {
|
||||
backgroundColor: "#ecfdf5",
|
||||
borderColor: "#bbf7d0",
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
gap: 10,
|
||||
marginBottom: 8,
|
||||
padding: 12,
|
||||
},
|
||||
tagPickerAiHeader: {
|
||||
alignItems: "flex-start",
|
||||
flexDirection: "row",
|
||||
gap: 10,
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
tagPickerAiHeaderText: {
|
||||
flex: 1,
|
||||
gap: 3,
|
||||
minWidth: 0,
|
||||
},
|
||||
tagPickerAiTitleRow: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
gap: 5,
|
||||
},
|
||||
tagPickerAiTitle: {
|
||||
color: "#065f46",
|
||||
fontSize: 14,
|
||||
fontWeight: "800",
|
||||
},
|
||||
tagPickerAiDescription: {
|
||||
color: "#047857",
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
},
|
||||
tagPickerAiGenerateButton: {
|
||||
alignItems: "center",
|
||||
backgroundColor: "#047857",
|
||||
borderRadius: 8,
|
||||
flexDirection: "row",
|
||||
gap: 5,
|
||||
minHeight: 34,
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
tagPickerAiGenerateText: {
|
||||
color: "#ffffff",
|
||||
fontSize: 12,
|
||||
fontWeight: "800",
|
||||
},
|
||||
tagPickerAiError: {
|
||||
color: "#be123c",
|
||||
fontSize: 12,
|
||||
fontWeight: "700",
|
||||
},
|
||||
tagPickerAiSuggestions: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 7,
|
||||
},
|
||||
tagPickerAiSuggestion: {
|
||||
alignItems: "center",
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "#a7f3d0",
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
flexDirection: "row",
|
||||
gap: 4,
|
||||
minHeight: 30,
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
tagPickerAiSuggestionSelected: {
|
||||
backgroundColor: "#047857",
|
||||
borderColor: "#047857",
|
||||
},
|
||||
tagPickerAiSuggestionAdded: {
|
||||
backgroundColor: "#d1fae5",
|
||||
borderColor: "#a7f3d0",
|
||||
},
|
||||
tagPickerAiSuggestionText: {
|
||||
color: "#065f46",
|
||||
fontSize: 12,
|
||||
fontWeight: "700",
|
||||
},
|
||||
tagPickerAiSuggestionTextSelected: {
|
||||
color: "#ffffff",
|
||||
},
|
||||
tagPickerAiSuggestionTextAdded: {
|
||||
color: "#047857",
|
||||
},
|
||||
tagPickerAiApplyButton: {
|
||||
alignItems: "center",
|
||||
alignSelf: "flex-start",
|
||||
backgroundColor: "#0f172a",
|
||||
borderRadius: 8,
|
||||
minHeight: 34,
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 11,
|
||||
},
|
||||
tagPickerAiApplyText: {
|
||||
color: "#ffffff",
|
||||
fontSize: 12,
|
||||
fontWeight: "800",
|
||||
},
|
||||
tagPickerLoading: {
|
||||
paddingVertical: 28,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user