Repository navigation
feat(ai): answer inline suggestions and query actions with Apple Intelligence - #3749
Conversation
…story whole around on-device turns
J2TeamNNL
left a comment
There was a problem hiding this comment.
Code review: 8 findings, inline.
| for try await snapshot in session.streamResponse(to: request.prompt, options: options) { | ||
| let content = snapshot.content | ||
| guard let delta = OnDeviceSnapshot.delta(from: emitted, to: content) else { | ||
| Self.logger.fault("On-device snapshot did not extend the text already shown") |
There was a problem hiding this comment.
When a streamed snapshot is not an extension of emitted, this logs a fault and continues without updating emitted. Every later snapshot is compared against the stale prefix, so the reply freezes at the pre-divergence text and the stream finishes normally. If FoundationModels revises earlier text mid-stream (retracted partial token, whitespace normalisation), the user sees a silently truncated answer with no error.
| self.omittedColumnCount = omittedColumnCount | ||
| } | ||
|
|
||
| func keepingColumns(named used: Set<String>) -> QueryContextTableStructure { |
There was a problem hiding this comment.
keepingColumns(named:) drops every column the statement does not name, so SELECT * keeps zero columns. For Explain/Review/Optimize on SELECT * FROM big_table with an oversized structure, the trimmed snapshot lists no columns and says none are named in the statement, which is misleading since * covers all. Keep all columns or refuse for wildcards. The match is also exact on lowercased tokens, so a quoted/backticked identifier the scanner does not unquote loses its column too.
| let wasLast = lastAssistantIndex == messages.count - 1 | ||
| messages.remove(at: lastAssistantIndex) | ||
| if wasLast, answeredOnDevice, let asked = messages.last, asked.role == .user { | ||
| onDeviceTurnID = asked.id |
There was a problem hiding this comment.
Regenerating the last on-device answer re-arms onDeviceTurnID from the old turn's providerId, ignoring the current useAppleIntelligence setting and availability. If the user turned Apple Intelligence off and configured a provider, Regenerate still routes to the on-device model, and fails if it is now unavailable. Same for a reloaded persisted conversation.
| format: String(localized: "Apple Intelligence could not answer: %@"), | ||
| error.localizedDescription | ||
| ) | ||
| streamingState = .failed(nil) |
There was a problem hiding this comment.
On a generation error the partial assistant turn is finalized with .failed(nil) but the conversation is not persisted. The transcript keeps a truncated answer beside the error banner, the saved conversation differs from the visible one, and Regenerate treats the truncated reply as a valid last assistant turn (empty-turn cleanup only runs when there are no blocks).
| guard settings.enabled, settings.inlineSuggestionsEnabled, let active = settings.activeProvider else { | ||
| return .off | ||
| guard settings.enabled, settings.inlineSuggestionsEnabled else { return .off } | ||
| if settings.useAppleIntelligence { |
There was a problem hiding this comment.
With useAppleIntelligence on, resolve returns .appleIntelligence unconditionally and never falls back to the active provider. If the on-device model is unavailable (still downloading, or Apple Intelligence off in System Settings), requestSuggestion returns nil on every keystroke: inline suggestions go silently off even though a working provider exists.
| if settings.includeSchema, let provider = schemaProvider, | ||
| let schema = await provider.buildSchemaContextForAI(settings: settings), !schema.isEmpty { | ||
| candidates.append(OnDeviceRequest( | ||
| instructions: AIPromptTemplates.inlineSuggestSystemPrompt(schemaContext: schema), |
There was a problem hiding this comment.
The on-device inline prompt omits the connection's AI rules and database dialect that the chat-completion inline source supplies; only the generic inlineSuggestSystemPrompt is used. MySQL/Postgres connections with per-connection aiRules, or non-SQL engines, get generic SQL suggestions, unlike the provider path.
| return messages.last?.id == onDeviceTurnID | ||
| } | ||
|
|
||
| var fallbackProviderName: String? { |
There was a problem hiding this comment.
fallbackProviderName re-implements provider resolution (selected provider id, else active) instead of reusing AIProviderFactory.resolve. They can disagree (missing API key, copilot provider, override model), so "Send to X" can name a provider that startStreaming then rejects with "No AI provider configured".
|
|
||
| let fit = await model.fit(candidates, reservingForReply: Self.onDeviceReplyReserve) | ||
| guard !Task.isCancelled else { return } | ||
| if connection != nil, resolveConnectionPolicy(settings: services.appSettings.ai) == .never { |
There was a problem hiding this comment.
The connection-policy .never check runs only after the DB metadata fetch and token measurement; buildQueryContext already queried the connection's schema by then. Check the policy before the context build so a Never connection is not queried.
| // MARK: - Apple Intelligence | ||
|
|
||
| private var appleIntelligenceSection: some View { | ||
| let availability = SystemOnDeviceLanguageModel().availability |
There was a problem hiding this comment.
appleIntelligenceSection builds a new SystemOnDeviceLanguageModel() and reads availability on every body evaluation (the same pattern recurs in MainContentCoordinator+AIQueryActions on each toolbar refresh). That is repeated system calls on hot UI paths, and availability is neither cached nor observed, so the UI can show stale state after the user enables Apple Intelligence.
|
Follow-up to the review above. Fixes are in J2TeamNNL@d46aa69 Fixed:
Skipped:
Not built or linted locally (no Xcode build; swiftlint cannot load sourcekit here). CI will check. Cherry-pick: |
Summary
FoundationModels,SystemLanguageModel.default): no key, no server, no network request. The chat and Agent mode keep the active provider (AI Chat: Apple Intelligence transport via Foundation Models #1048).FoundationModelsloads weak.tokenCount(for:)on macOS 26.4+, a high-side estimate below it. An action tries the full table structure, then only the columns the statement names, matched in the connection's quoting. Neither fits: nothing is generated, the error says so and Send to answers the same turn through the active provider. The reply is capped at what is left of the context.apple/scripts/probes/check-on-device-model.shruns the shipped on-device code against the Mac's model and prints the token ratios the estimate rests on.Test plan
AIChatViewModelOnDeviceTests: an action answered on-device with no provider; too large refused before generation with the fallback offered; the fallback and a follow-up take the provider path; Regenerate, also of a restored answer, stays on-device; unavailable explains itself; Ask Each Time asks first; the setting off takes the provider path.AppleIntelligenceInlineSourceTests(incl. a policy turned to Never while measuring),OnDeviceFitTests,OnDeviceSnapshotTests(grapheme-extending snapshots),QueryContextUsedColumnsTests,AppleIntelligenceAvailabilityTests, new cases inAIQueryActionAvailabilityTests,InlineSuggestionSourceKindTestsandAISettingsTests. All fail on main: the types do not exist.check-on-device-model.shon macOS 27.0.1: available, context 8192, SQL 2.74 / table 2.87 / CJK 2.17 characters a token, oversized request refused, reply streamed.otool -lon the Debug app and on a macOS 13 target build:FoundationModelsisLC_LOAD_WEAK_DYLIB.Docs: TableProApp/docs#100
Fixes #3611