Compare commits

...

10 Commits

Author SHA1 Message Date
DarkSky
6e034185cf feat: title of session (#12971)
fix AI-253
2025-07-01 05:24:42 +00:00
Lakr
2be3f84196 fix: 🚑 compiler issue on newer syntax (#12974)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Refactor**
* Updated method and function signatures to accept any type conforming
to the chat cell view model protocol, improving flexibility and
extensibility of chat cell configuration and height estimation.
  * Simplified internal logic for determining text color in chat cells.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-01 13:06:06 +08:00
EYHN
f46d288b1b fix(core): fix client crash (#12966)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved reliability when displaying OAuth provider icons by handling
cases where the provider may not be recognized, preventing potential
errors during authentication.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-01 03:38:13 +00:00
Lakr
9529adf33e chore: remove intelligent button from release (#12970)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* The "Intelligents" button is now only shown in debug builds; it will
not appear in production versions.
* **Bug Fixes**
* Removed all references to the "Intelligents" plugin and related UI,
ensuring a cleaner production app experience.
* **Chores**
* Cleaned up project settings and removed redundant or empty
configuration entries.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-30 10:48:58 +00:00
Peng Xiao
03aeb44dc9 fix(editor): peekable conditions for edgeless note block (#12969)
fix AF-2704

#### PR Dependency Tree


* **PR #12969** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved peek behavior to allow peeking inside note blocks, even when
the hit target differs from the current model, as long as the current
model is contained within the note block. This enhances usability when
interacting with nested note blocks.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-30 07:13:02 +00:00
德布劳外 · 贾贵
c9aad0d55e refactor(core): open embedding settings when click check-status button (#12772)
> CLOSE BS-3582

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added a "Check status" button in the AI chat interface that opens the
embedding settings panel for improved user access.

- **Refactor**
- Simplified the embedding status tooltip to display static information
and a direct link to settings, removing dynamic progress updates.
- Integrated workspace dialog service across AI chat components for
consistent dialog management.

- **Tests**
- Updated end-to-end tests to verify that clicking the "Check status"
button opens the embedding settings panel.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-30 07:00:17 +00:00
Lakr
29ae6afe71 chore: created first ai stream (#12968)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Introduced a redesigned chat interface with new cell types for user,
assistant, system, loading, and error messages.
  * Added streaming chat responses and improved session management.
* Enhanced input box behavior, allowing sending messages with the return
key and inserting new lines via the edit menu.
* Added new GraphQL queries for fetching recent and latest chat
sessions.

* **Refactor**
* Replaced previous chat message and session management with a new, more
structured view model system.
* Updated chat view to use a custom table view component for better
message rendering and empty state handling.
* Simplified and improved error and loading state handling in the chat
UI.

* **Bug Fixes**
  * Improved error reporting and retry options for failed chat messages.
  * Fixed inconsistent property types for message and error identifiers.

* **Style**
* Updated UI components for chat cells with modern layouts and
consistent styling.

* **Chores**
  * Added a new package dependency for event streaming support.
* Renamed various internal properties and classes for clarity and
consistency.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-30 06:51:00 +00:00
Cats Juice
32787bc88b fix(core): fix ai input style in chat block and simply img rendering (#12943)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Style**
* Improved visual styling and cursor behavior for chat input send, stop,
and preference trigger buttons.
* Enhanced appearance and interactivity cues for the chat input
preference trigger.

* **Refactor**
* Simplified image preview grid by using CSS hover states for close
button visibility and switching to background images for previews.
* Streamlined image deletion process for a more intuitive user
experience.

* **Tests**
* Updated image upload test to wait for image container elements,
improving test reliability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-30 06:22:04 +00:00
EYHN
bbafce2c40 fix(ios): fix testflight (#12964)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Updated internal workflow configuration for iOS TestFlight uploads. No
impact on app features or user experience.
* Improved version handling to preserve full version strings for iOS
marketing versions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-30 06:06:21 +00:00
Peng Xiao
f7f69c3bc4 chore(core): do remove timeout for audio transcription job (#12965)
#### PR Dependency Tree


* **PR #12965** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved request timeout handling to ensure timeouts are only set when
appropriate and provide clearer error messages.
* Updated audio transcription submission to wait indefinitely for
completion, preventing requests from being aborted due to timeouts.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-30 06:00:53 +00:00
79 changed files with 2199 additions and 1110 deletions

View File

@@ -132,6 +132,7 @@ jobs:
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
fastlane beta
env:
BUILD_TARGET: distribution
BUILD_PROVISION_PROFILE: ${{ secrets.BUILD_PROVISION_PROFILE }}
PP_PATH: ${{ runner.temp }}/build_pp.mobileprovision
APPLE_STORE_CONNECT_API_KEY_ID: ${{ secrets.APPLE_STORE_CONNECT_API_KEY_ID }}

View File

@@ -1,7 +1,14 @@
import { NoteBlockModel } from '@blocksuite/affine-model';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import { isInsideEdgelessEditor } from '@blocksuite/affine-shared/utils';
import {
isInsideEdgelessEditor,
matchModels,
} from '@blocksuite/affine-shared/utils';
import type { Constructor } from '@blocksuite/global/utils';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import {
GfxBlockElementModel,
GfxControllerIdentifier,
} from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store';
import type { LitElement, TemplateResult } from 'lit';
@@ -72,6 +79,20 @@ export const Peekable =
);
if (hitTarget && hitTarget !== model) {
// Check if hitTarget is a GfxBlockElementModel (which extends BlockModel)
// and if it's a NoteBlockModel, then check if current model is inside it
if (
hitTarget instanceof GfxBlockElementModel &&
matchModels(hitTarget, [NoteBlockModel])
) {
let curModel: BlockModel | null = model;
while (curModel) {
if (curModel === hitTarget) {
return true; // Model is inside the NoteBlockModel, allow peek
}
curModel = curModel.parent;
}
}
return false;
}

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ai_sessions_metadata" ADD COLUMN "title" VARCHAR;

View File

@@ -122,15 +122,15 @@ model Workspace {
avatarKey String? @map("avatar_key") @db.VarChar
indexed Boolean @default(false)
features WorkspaceFeature[]
docs WorkspaceDoc[]
permissions WorkspaceUserRole[]
docPermissions WorkspaceDocUserRole[]
blobs Blob[]
ignoredDocs AiWorkspaceIgnoredDocs[]
embedFiles AiWorkspaceFiles[]
comments Comment[]
commentAttachments CommentAttachment[]
features WorkspaceFeature[]
docs WorkspaceDoc[]
permissions WorkspaceUserRole[]
docPermissions WorkspaceDocUserRole[]
blobs Blob[]
ignoredDocs AiWorkspaceIgnoredDocs[]
embedFiles AiWorkspaceFiles[]
comments Comment[]
commentAttachments CommentAttachment[]
@@map("workspaces")
}
@@ -443,6 +443,7 @@ model AiSession {
promptName String @map("prompt_name") @db.VarChar(32)
promptAction String? @default("") @map("prompt_action") @db.VarChar(32)
pinned Boolean @default(false)
title String? @db.VarChar
// the session id of the parent session if this session is a forked session
parentSessionId String? @map("parent_session_id") @db.VarChar
messageCost Int @default(0)
@@ -900,8 +901,8 @@ model Reply {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
@@index([commentId, sid])
@@index([workspaceId, docId, updatedAt])
@@ -911,19 +912,19 @@ model Reply {
model CommentAttachment {
// NOTE: manually set this column type to identity in migration file
sid Int @unique @default(autoincrement())
workspaceId String @map("workspace_id") @db.VarChar
docId String @map("doc_id") @db.VarChar
key String @db.VarChar
size Int @db.Integer
mime String @db.VarChar
name String @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
createdBy String? @map("created_by") @db.VarChar
sid Int @unique @default(autoincrement())
workspaceId String @map("workspace_id") @db.VarChar
docId String @map("doc_id") @db.VarChar
key String @db.VarChar
size Int @db.Integer
mime String @db.VarChar
name String @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
createdBy String? @map("created_by") @db.VarChar
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
// will delete creator record if creator's account is deleted
createdByUser User? @relation(name: "createdCommentAttachments", fields: [createdBy], references: [id], onDelete: SetNull)
createdByUser User? @relation(name: "createdCommentAttachments", fields: [createdBy], references: [id], onDelete: SetNull)
@@id([workspaceId, docId, key])
@@map("comment_attachments")

View File

@@ -330,3 +330,45 @@ Generated by [AVA](https://avajs.dev).
],
{},
]
## should handle generateSessionTitle correctly under various conditions
> should generate title when conditions are met
{
chatWithPromptCalled: undefined,
exists: true,
title: 'What is Machine Learning?',
}
> should not generate title when session already has title
{
chatWithPromptCalled: false,
exists: true,
title: 'Existing Title',
}
> should not generate title when no user messages exist
{
chatWithPromptCalled: false,
exists: true,
title: null,
}
> should not generate title when no assistant messages exist
{
chatWithPromptCalled: false,
exists: true,
title: null,
}
> should use correct prompt for title generation
{
content: `[user]: Explain quantum computing briefly␊
[assistant]: Quantum computing uses quantum mechanics principles.`,
promptName: 'Summary as title',
}

View File

@@ -11,7 +11,11 @@ import { EventBus, JobQueue } from '../base';
import { ConfigModule } from '../base/config';
import { AuthService } from '../core/auth';
import { QuotaModule } from '../core/quota';
import { ContextCategories, WorkspaceModel } from '../models';
import {
ContextCategories,
CopilotSessionModel,
WorkspaceModel,
} from '../models';
import { CopilotModule } from '../plugins/copilot';
import { CopilotContextService } from '../plugins/copilot/context';
import {
@@ -57,12 +61,13 @@ import { MockCopilotProvider } from './mocks';
import { createTestingModule, TestingModule } from './utils';
import { WorkflowTestCases } from './utils/copilot';
const test = ava as TestFn<{
type Context = {
auth: AuthService;
module: TestingModule;
db: PrismaClient;
event: EventBus;
workspace: WorkspaceModel;
copilotSession: CopilotSessionModel;
context: CopilotContextService;
prompt: PromptService;
transcript: CopilotTranscriptionService;
@@ -78,7 +83,8 @@ const test = ava as TestFn<{
html: CopilotCheckHtmlExecutor;
json: CopilotCheckJsonExecutor;
};
}>;
};
const test = ava as TestFn<Context>;
let userId: string;
test.before(async t => {
@@ -119,6 +125,7 @@ test.before(async t => {
const db = module.get(PrismaClient);
const event = module.get(EventBus);
const workspace = module.get(WorkspaceModel);
const copilotSession = module.get(CopilotSessionModel);
const prompt = module.get(PromptService);
const factory = module.get(CopilotProviderFactory);
@@ -136,6 +143,7 @@ test.before(async t => {
t.context.db = db;
t.context.event = event;
t.context.workspace = workspace;
t.context.copilotSession = copilotSession;
t.context.prompt = prompt;
t.context.factory = factory;
t.context.session = session;
@@ -1752,3 +1760,168 @@ test('should be able to manage workspace embedding', async t => {
t.is(ret2.length, 0, 'should not match workspace context');
}
});
test('should handle generateSessionTitle correctly under various conditions', async t => {
const { prompt, session, workspace, copilotSession } = t.context;
await prompt.set('test', 'model', [{ role: 'user', content: '{{content}}' }]);
const createSession = async (
options: {
userMessage?: string;
assistantMessage?: string;
existingTitle?: string;
} = {}
) => {
const ws = await workspace.create(userId);
const sessionId = await session.create({
docId: 'test-doc',
workspaceId: ws.id,
userId,
promptName: 'test',
pinned: false,
});
if (options.existingTitle) {
await copilotSession.update({
userId,
sessionId,
title: options.existingTitle,
});
}
const chatSession = await session.get(sessionId);
if (chatSession) {
if (options.userMessage) {
chatSession.push({
role: 'user',
content: options.userMessage,
createdAt: new Date(),
});
}
if (options.assistantMessage) {
chatSession.push({
role: 'assistant',
content: options.assistantMessage,
createdAt: new Date(),
});
}
await chatSession.save();
}
return sessionId;
};
const testCases = [
{
name: 'should generate title when conditions are met',
setup: () =>
createSession({
userMessage: 'What is machine learning?',
assistantMessage:
'Machine learning is a subset of artificial intelligence.',
}),
mockFn: () => 'What is Machine Learning?',
expectSnapshot: true,
},
{
name: 'should not generate title when session already has title',
setup: () =>
createSession({
userMessage: 'Test message',
assistantMessage: 'Test response',
existingTitle: 'Existing Title',
}),
mockFn: () => 'New Title',
expectSnapshot: true,
expectNotCalled: true,
},
{
name: 'should not generate title when no user messages exist',
setup: () =>
createSession({ assistantMessage: 'Hello! How can I help you?' }),
mockFn: () => 'New Title',
expectSnapshot: true,
expectNotCalled: true,
},
{
name: 'should not generate title when no assistant messages exist',
setup: () => createSession({ userMessage: 'What is AI?' }),
mockFn: () => 'New Title',
expectSnapshot: true,
expectNotCalled: true,
},
{
name: 'should handle errors gracefully',
setup: () =>
createSession({
userMessage: 'Test question',
assistantMessage: 'Test answer',
}),
mockFn: () => {
throw new Error('Mock error for testing');
},
expectError: 'Mock error for testing',
},
];
for (const testCase of testCases) {
const sessionId = await testCase.setup();
let chatWithPromptCalled = false;
const mockStub = Sinon.stub(session, 'chatWithPrompt').callsFake(
async () => {
chatWithPromptCalled = true;
return testCase.mockFn();
}
);
if (testCase.expectError) {
await t.throwsAsync(
() => session.generateSessionTitle({ sessionId }),
{ message: testCase.expectError },
testCase.name
);
} else {
await session.generateSessionTitle({ sessionId });
if (testCase.expectSnapshot) {
const sessionState = await session.getSession(sessionId);
t.snapshot(
{
chatWithPromptCalled: testCase.expectNotCalled
? chatWithPromptCalled
: undefined,
title: sessionState?.title,
exists: !!sessionState,
},
testCase.name
);
}
}
mockStub.restore();
}
{
const sessionId = await createSession({
userMessage: 'Explain quantum computing briefly',
assistantMessage: 'Quantum computing uses quantum mechanics principles.',
});
let capturedArgs: any[] = [];
Sinon.stub(session, 'chatWithPrompt').callsFake(async (...args) => {
capturedArgs = args;
return 'Quantum Computing Explained';
});
await session.generateSessionTitle({ sessionId });
t.snapshot(
{
promptName: capturedArgs[0],
content: capturedArgs[1]?.content,
},
'should use correct prompt for title generation'
);
}
});

View File

@@ -58,6 +58,7 @@ test.beforeEach(async t => {
workspaceId: workspace.id,
docId,
userId: user.id,
title: null,
promptName: 'prompt-name',
promptAction: null,
});

View File

@@ -82,6 +82,7 @@ const createTestSession = async (
workspaceId: workspace.id,
docId: null,
pinned: false,
title: null,
promptName: TEST_PROMPTS.NORMAL,
promptAction: null,
...overrides,
@@ -297,6 +298,7 @@ test('should pin and unpin sessions', async t => {
promptName: 'test-prompt',
promptAction: null,
pinned: true,
title: null,
});
const firstSession = await copilotSession.get(firstSessionId);
@@ -312,6 +314,7 @@ test('should pin and unpin sessions', async t => {
promptName: 'test-prompt',
promptAction: null,
pinned: true,
title: null,
});
const sessionStatesAfterSecondPin = await getSessionStates(db, [
@@ -796,6 +799,7 @@ test('should handle fork and session attachment operations', async t => {
workspaceId: workspace.id,
docId: forkConfig.docId,
pinned: forkConfig.pinned,
title: null,
parentSessionId,
prompt: { name: TEST_PROMPTS.NORMAL, action: null, model: 'gpt-4.1' },
messages: [

View File

@@ -50,6 +50,7 @@ type PureChatSession = {
workspaceId: string;
docId?: string | null;
pinned?: boolean;
title: string | null;
messages?: ChatMessage[];
// connect ids
userId: string;
@@ -82,7 +83,7 @@ type UpdateChatSessionMessage = ChatSessionBaseState & {
};
export type UpdateChatSessionOptions = ChatSessionBaseState &
Pick<Partial<ChatSession>, 'docId' | 'pinned' | 'promptName'>;
Pick<Partial<ChatSession>, 'docId' | 'pinned' | 'promptName' | 'title'>;
export type UpdateChatSession = ChatSessionBaseState & UpdateChatSessionOptions;
@@ -254,7 +255,7 @@ export class CopilotSessionModel extends BaseModel {
return (await this.db.aiSession.findUnique({
where: { ...where, id: sessionId, deletedAt: null },
select,
})) as Prisma.AiSessionGetPayload<{ select: Select }>;
})) as Prisma.AiSessionGetPayload<{ select: Select }> | null;
}
@Transactional()
@@ -266,6 +267,7 @@ export class CopilotSessionModel extends BaseModel {
docId: true,
pinned: true,
parentSessionId: true,
title: true,
messages: {
select: {
id: true,
@@ -331,6 +333,7 @@ export class CopilotSessionModel extends BaseModel {
docId: true,
parentSessionId: true,
pinned: true,
title: true,
promptName: true,
tokenCost: true,
createdAt: true,
@@ -373,7 +376,7 @@ export class CopilotSessionModel extends BaseModel {
@Transactional()
async update(options: UpdateChatSessionOptions): Promise<string> {
const { userId, sessionId, docId, promptName, pinned } = options;
const { userId, sessionId, docId, promptName, pinned, title } = options;
const session = await this.getExists(
sessionId,
{
@@ -419,7 +422,7 @@ export class CopilotSessionModel extends BaseModel {
await this.db.aiSession.update({
where: { id: sessionId },
data: { docId, promptName, pinned },
data: { docId, promptName, pinned, title },
});
return sessionId;
@@ -522,17 +525,29 @@ export class CopilotSessionModel extends BaseModel {
if (!id) {
throw new CopilotSessionNotFound();
}
const ids = await this.getMessages(id, { id: true, role: true }).then(
roles =>
roles
.slice(
roles.findLastIndex(({ role }) => role === AiPromptRole.user) +
(removeLatestUserMessage ? 0 : 1)
)
.map(({ id }) => id)
);
const messages = await this.getMessages(id, { id: true, role: true });
const ids = messages
.slice(
messages.findLastIndex(({ role }) => role === AiPromptRole.user) +
(removeLatestUserMessage ? 0 : 1)
)
.map(({ id }) => id);
if (ids.length) {
await this.db.aiSessionMessage.deleteMany({ where: { id: { in: ids } } });
// clear the title if there only one round of conversation left
const remainingMessages = await this.getMessages(id, { role: true });
const userMessageCount = remainingMessages.filter(
m => m.role === AiPromptRole.user
).length;
if (userMessageCount <= 1) {
await this.db.aiSession.update({
where: { id },
data: { title: null },
});
}
}
}

View File

@@ -67,7 +67,9 @@ class CreateChatSessionInput {
}
@InputType()
class UpdateChatSessionInput implements Omit<UpdateChatSession, 'userId'> {
class UpdateChatSessionInput
implements Omit<UpdateChatSession, 'userId' | 'title'>
{
@Field(() => String)
sessionId!: string;
@@ -336,6 +338,9 @@ export class CopilotSessionType {
@Field(() => Boolean)
pinned!: boolean;
@Field(() => String, { nullable: true })
title!: string | null;
@Field(() => ID, { nullable: true })
parentSessionId!: string | null;
@@ -653,6 +658,7 @@ export class CopilotResolver {
parentSessionId: session.parentSessionId,
docId: session.docId,
pinned: session.pinned,
title: session.title,
promptName: session.prompt.name,
model: session.prompt.model,
optionalModels: session.prompt.optionalModels,

View File

@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import { Injectable, Logger } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { Transactional } from '@nestjs-cls/transactional';
import { AiPromptRole } from '@prisma/client';
@@ -11,6 +12,9 @@ import {
CopilotQuotaExceeded,
CopilotSessionInvalidInput,
CopilotSessionNotFound,
JobQueue,
NoCopilotProviderAvailable,
OnJob,
} from '../../base';
import { QuotaService } from '../../core/quota';
import {
@@ -22,7 +26,12 @@ import {
} from '../../models';
import { ChatMessageCache } from './message';
import { PromptService } from './prompt';
import { PromptMessage, PromptParams } from './providers';
import {
CopilotProviderFactory,
ModelOutputType,
PromptMessage,
PromptParams,
} from './providers';
import {
type ChatHistory,
type ChatMessage,
@@ -33,6 +42,14 @@ import {
type SubmittedMessage,
} from './types';
declare global {
interface Jobs {
'copilot.session.generateTitle': {
sessionId: string;
};
}
}
export class ChatSession implements AsyncDisposable {
private stashMessageCount = 0;
constructor(
@@ -224,10 +241,12 @@ export class ChatSessionService {
private readonly logger = new Logger(ChatSessionService.name);
constructor(
private readonly moduleRef: ModuleRef,
private readonly models: Models,
private readonly jobs: JobQueue,
private readonly quota: QuotaService,
private readonly messageCache: ChatMessageCache,
private readonly prompt: PromptService,
private readonly models: Models
private readonly prompt: PromptService
) {}
async getSession(sessionId: string): Promise<ChatSessionState | undefined> {
@@ -244,6 +263,7 @@ export class ChatSessionService {
workspaceId: session.workspaceId,
docId: session.docId,
pinned: session.pinned,
title: session.title,
parentSessionId: session.parentSessionId,
prompt,
messages: messages.success ? messages.data : [],
@@ -282,6 +302,7 @@ export class ChatSessionService {
workspaceId: session.workspaceId,
docId: session.docId,
pinned: session.pinned,
title: session.title,
parentSessionId: session.parentSessionId,
prompt,
};
@@ -303,6 +324,7 @@ export class ChatSessionService {
workspaceId,
docId,
pinned,
title,
promptName,
tokenCost,
messages,
@@ -347,6 +369,7 @@ export class ChatSessionService {
workspaceId,
docId,
pinned,
title,
action: prompt.action || null,
tokens: tokenCost,
createdAt,
@@ -418,6 +441,7 @@ export class ChatSessionService {
...options,
sessionId,
prompt,
title: null,
messages: [],
// when client create chat session, we always find root session
parentSessionId: null,
@@ -520,8 +544,78 @@ export class ChatSessionService {
if (state) {
return new ChatSession(this.messageCache, state, async state => {
await this.models.copilotSession.updateMessages(state);
if (!state.prompt.action) {
await this.jobs.add('copilot.session.generateTitle', { sessionId });
}
});
}
return null;
}
// public for test mock
async chatWithPrompt(
promptName: string,
message: Partial<PromptMessage>
): Promise<string> {
const prompt = await this.prompt.get(promptName);
if (!prompt) {
throw new CopilotPromptNotFound({ name: promptName });
}
const cond = { modelId: prompt.model };
const msg = { role: 'user' as const, content: '', ...message };
const config = Object.assign({}, prompt.config);
const provider = await this.moduleRef
.get(CopilotProviderFactory)
.getProvider({
outputType: ModelOutputType.Text,
modelId: prompt.model,
});
if (!provider) {
throw new NoCopilotProviderAvailable();
}
return provider.text(cond, [...prompt.finish({}), msg], config);
}
@OnJob('copilot.session.generateTitle')
async generateSessionTitle(job: Jobs['copilot.session.generateTitle']) {
const { sessionId } = job;
try {
const session = await this.models.copilotSession.get(sessionId);
if (!session) {
this.logger.warn(
`Session ${sessionId} not found when generating title`
);
return;
}
const { userId, title, messages } = session;
if (
title ||
!messages.length ||
messages.filter(m => m.role === 'user').length === 0 ||
messages.filter(m => m.role === 'assistant').length === 0
) {
return;
}
{
const title = await this.chatWithPrompt('Summary as title', {
content: session.messages
.map(m => `[${m.role}]: ${m.content}`)
.join('\n'),
});
await this.models.copilotSession.update({ userId, sessionId, title });
}
} catch (error) {
console.error(
`Failed to generate title for session ${sessionId}:`,
error
);
throw error;
}
}
}

View File

@@ -50,6 +50,7 @@ export const ChatHistorySchema = z
workspaceId: z.string(),
docId: z.string().nullable(),
pinned: z.boolean(),
title: z.string().nullable(),
action: z.string().nullable(),
tokens: z.number(),
messages: z.array(ChatMessageSchema),
@@ -85,6 +86,7 @@ export interface ChatSessionForkOptions
export interface ChatSessionState
extends Omit<ChatSessionOptions, 'promptName'> {
title: string | null;
// connect ids
sessionId: string;
parentSessionId: string | null;

View File

@@ -324,6 +324,7 @@ type CopilotSessionType {
parentSessionId: ID
pinned: Boolean!
promptName: String!
title: String
}
type CopilotWorkspaceConfig {

View File

@@ -9,6 +9,7 @@ query getCopilotSession(
parentSessionId
docId
pinned
title
promptName
model
optionalModels

View File

@@ -10,6 +10,7 @@ query getCopilotSessions(
parentSessionId
docId
pinned
title
promptName
model
optionalModels

View File

@@ -799,6 +799,7 @@ export const getCopilotSessionQuery = {
parentSessionId
docId
pinned
title
promptName
model
optionalModels
@@ -848,6 +849,7 @@ export const getCopilotSessionsQuery = {
parentSessionId
docId
pinned
title
promptName
model
optionalModels

View File

@@ -419,6 +419,7 @@ export interface CopilotSessionType {
parentSessionId: Maybe<Scalars['ID']['output']>;
pinned: Scalars['Boolean']['output'];
promptName: Scalars['String']['output'];
title: Maybe<Scalars['String']['output']>;
}
export interface CopilotWorkspaceConfig {
@@ -3619,6 +3620,7 @@ export type GetCopilotSessionQuery = {
parentSessionId: string | null;
docId: string | null;
pinned: boolean;
title: string | null;
promptName: string;
model: string;
optionalModels: Array<string>;
@@ -3680,6 +3682,7 @@ export type GetCopilotSessionsQuery = {
parentSessionId: string | null;
docId: string | null;
pinned: boolean;
title: string | null;
promptName: string;
model: string;
optionalModels: Array<string>;

View File

@@ -3,13 +3,12 @@
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
2E0DD47B57B994A104B25EED /* Pods_AFFiNE.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF48636D7DB5BEE00770FD9A /* Pods_AFFiNE.framework */; };
5075136A2D1924C600AD60C0 /* RootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 507513692D1924C600AD60C0 /* RootViewController.swift */; };
5075136E2D1925BC00AD60C0 /* IntelligentsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5075136D2D1925BC00AD60C0 /* IntelligentsPlugin.swift */; };
50802D612D112F8700694021 /* Intelligents in Frameworks */ = {isa = PBXBuildFile; productRef = 50802D602D112F8700694021 /* Intelligents */; settings = {ATTRIBUTES = (Required, ); }; };
50A285D72D112A5E000D5A6D /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D62D112A5E000D5A6D /* Localizable.xcstrings */; };
50A285D82D112A5E000D5A6D /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */; };
@@ -56,7 +55,6 @@
5039CC962D1D42C700874F32 /* AffineGraphQL */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AffineGraphQL; sourceTree = "<group>"; };
504EC3041FED79650016851F /* AFFiNE.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AFFiNE.app; sourceTree = BUILT_PRODUCTS_DIR; };
507513692D1924C600AD60C0 /* RootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewController.swift; sourceTree = "<group>"; };
5075136D2D1925BC00AD60C0 /* IntelligentsPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntelligentsPlugin.swift; sourceTree = "<group>"; };
50802D5E2D112F7D00694021 /* Intelligents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Intelligents; sourceTree = "<group>"; };
50A285D52D112A5E000D5A6D /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
50A285D62D112A5E000D5A6D /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
@@ -92,8 +90,6 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
C45499AB2D140B5000E21978 /* NBStore */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = NBStore;
sourceTree = "<group>";
};
@@ -179,7 +175,6 @@
9D90BE172CCB9876006677DB /* CookieManager.swift */,
9D90BE182CCB9876006677DB /* CookiePlugin.swift */,
9D6A85322CCF6DA700DAB35F /* HashcashPlugin.swift */,
5075136D2D1925BC00AD60C0 /* IntelligentsPlugin.swift */,
);
path = Cookie;
sourceTree = "<group>";
@@ -342,9 +337,13 @@
);
inputFileListPaths = (
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n";
@@ -376,7 +375,6 @@
buildActionMask = 2147483647;
files = (
9D52FC432D26CDBF00105D0A /* JSValueContainerExt.swift in Sources */,
5075136E2D1925BC00AD60C0 /* IntelligentsPlugin.swift in Sources */,
5075136A2D1924C600AD60C0 /* RootViewController.swift in Sources */,
C4C97C7C2D030BE000BC2AD1 /* affine_mobile_native.swift in Sources */,
9DAE9BD92D8D1AB0000C1D5A /* AppConfigManager.swift in Sources */,

View File

@@ -9,6 +9,15 @@
"version" : "1.22.0"
}
},
{
"identity" : "eventsource",
"kind" : "remoteSourceControl",
"location" : "https://github.com/loopwork-ai/eventsource.git",
"state" : {
"revision" : "07957602bb99a5355c810187e66e6ce378a1057d",
"version" : "1.1.1"
}
},
{
"identity" : "snapkit",
"kind" : "remoteSourceControl",

View File

@@ -10,24 +10,26 @@ import UIKit
extension AFFiNEViewController: IntelligentsButtonDelegate {
func onIntelligentsButtonTapped(_ button: IntelligentsButton) {
IntelligentContext.shared.webView = webView!
button.beginProgress()
IntelligentContext.shared.preparePresent { result in
button.stopProgress()
switch result {
case .success:
let controller = IntelligentsController()
self.present(controller, animated: true)
case let .failure(failure):
let alert = UIAlertController(
title: "Error",
message: failure.localizedDescription,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default))
self.present(alert, animated: true)
}
}
// if it shows up then we are ready to go
let controller = IntelligentsController()
self.present(controller, animated: true)
// IntelligentContext.shared.webView = webView
// button.beginProgress()
// IntelligentContext.shared.preparePresent { result in
// DispatchQueue.main.async {
// button.stopProgress()
// switch result {
// case .success:
// case let .failure(failure):
// let alert = UIAlertController(
// title: "Error",
// message: failure.localizedDescription,
// preferredStyle: .alert
// )
// alert.addAction(UIAlertAction(title: "OK", style: .default))
// self.present(alert, animated: true)
// }
// }
// }
}
}

View File

@@ -3,6 +3,8 @@ import Intelligents
import UIKit
class AFFiNEViewController: CAPBridgeViewController {
var intelligentsButton: IntelligentsButton?
override func viewDidLoad() {
super.viewDidLoad()
webView?.allowsBackForwardNavigationGestures = true
@@ -11,6 +13,7 @@ class AFFiNEViewController: CAPBridgeViewController {
edgesForExtendedLayout = []
let intelligentsButton = installIntelligentsButton()
intelligentsButton.delegate = self
self.intelligentsButton = intelligentsButton
dismissIntelligentsButton()
}
@@ -35,11 +38,45 @@ class AFFiNEViewController: CAPBridgeViewController {
plugins.forEach { bridge?.registerPluginInstance($0) }
}
private var intelligentsButtonTimer: Timer?
private var isCheckingIntelligentEligibility = false
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
IntelligentContext.shared.webView = webView
navigationController?.setNavigationBarHidden(false, animated: animated)
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.presentIntelligentsButton()
let timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self] _ in
self?.checkEligibilityOfIntelligent()
}
intelligentsButtonTimer = timer
RunLoop.main.add(timer, forMode: .common)
}
private func checkEligibilityOfIntelligent() {
guard !isCheckingIntelligentEligibility else { return }
assert(intelligentsButton != nil)
guard intelligentsButton?.isHidden ?? false else { return } // already eligible
isCheckingIntelligentEligibility = true
IntelligentContext.shared.webView = webView
IntelligentContext.shared.preparePresent { [self] result in
DispatchQueue.main.async {
defer { self.isCheckingIntelligentEligibility = false }
switch result {
case .failure: break
case .success:
#if DEBUG
// only show the button in debug mode before we get done
self.presentIntelligentsButton()
#else
break
#endif
}
}
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
intelligentsButtonTimer?.invalidate()
}
}

View File

@@ -1,36 +0,0 @@
import Capacitor
import Foundation
// @objc(IntelligentsPlugin)
// public class IntelligentsPlugin: CAPPlugin, CAPBridgedPlugin {
// public let identifier = "IntelligentsPlugin"
// public let jsName = "Intelligents"
// public let pluginMethods: [CAPPluginMethod] = [
// CAPPluginMethod(name: "presentIntelligentsButton", returnType: CAPPluginReturnPromise),
// CAPPluginMethod(name: "dismissIntelligentsButton", returnType: CAPPluginReturnPromise),
// ]
// public private(set) weak var representController: UIViewController?
//
// init(representController: UIViewController) {
// self.representController = representController
// super.init()
// }
//
// deinit {
// representController = nil
// }
//
// @objc public func presentIntelligentsButton(_ call: CAPPluginCall) {
// DispatchQueue.main.async {
// self.representController?.presentIntelligentsButton()
// call.resolve()
// }
// }
//
// @objc public func dismissIntelligentsButton(_ call: CAPPluginCall) {
// DispatchQueue.main.async {
// self.representController?.dismissIntelligentsButton()
// call.resolve()
// }
// }
// }

View File

@@ -0,0 +1,141 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class GetCopilotLatestDocSessionQuery: GraphQLQuery {
public static let operationName: String = "getCopilotLatestDocSession"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query getCopilotLatestDocSession($workspaceId: String!, $docId: String!) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename histories( docId: $docId options: { limit: 1, sessionOrder: desc, action: false, fork: false } ) { __typename sessionId workspaceId docId pinned action tokens createdAt updatedAt messages { __typename id role content attachments params createdAt } } } } }"#
))
public var workspaceId: String
public var docId: String
public init(
workspaceId: String,
docId: String
) {
self.workspaceId = workspaceId
self.docId = docId
}
public var __variables: Variables? { [
"workspaceId": workspaceId,
"docId": docId
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("currentUser", CurrentUser?.self),
] }
/// Get current user
public var currentUser: CurrentUser? { __data["currentUser"] }
/// CurrentUser
///
/// Parent Type: `UserType`
public struct CurrentUser: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("copilot", Copilot.self, arguments: ["workspaceId": .variable("workspaceId")]),
] }
public var copilot: Copilot { __data["copilot"] }
/// CurrentUser.Copilot
///
/// Parent Type: `Copilot`
public struct Copilot: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Copilot }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("histories", [History].self, arguments: [
"docId": .variable("docId"),
"options": [
"limit": 1,
"sessionOrder": "desc",
"action": false,
"fork": false
]
]),
] }
public var histories: [History] { __data["histories"] }
/// CurrentUser.Copilot.History
///
/// Parent Type: `CopilotHistories`
public struct History: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CopilotHistories }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("sessionId", String.self),
.field("workspaceId", String.self),
.field("docId", String?.self),
.field("pinned", Bool.self),
.field("action", String?.self),
.field("tokens", Int.self),
.field("createdAt", AffineGraphQL.DateTime.self),
.field("updatedAt", AffineGraphQL.DateTime.self),
.field("messages", [Message].self),
] }
public var sessionId: String { __data["sessionId"] }
public var workspaceId: String { __data["workspaceId"] }
public var docId: String? { __data["docId"] }
public var pinned: Bool { __data["pinned"] }
/// An mark identifying which view to use to display the session
public var action: String? { __data["action"] }
/// The number of tokens used in the session
public var tokens: Int { __data["tokens"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var updatedAt: AffineGraphQL.DateTime { __data["updatedAt"] }
public var messages: [Message] { __data["messages"] }
/// CurrentUser.Copilot.History.Message
///
/// Parent Type: `ChatMessage`
public struct Message: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.ChatMessage }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("id", AffineGraphQL.ID?.self),
.field("role", String.self),
.field("content", String.self),
.field("attachments", [String]?.self),
.field("params", AffineGraphQL.JSON?.self),
.field("createdAt", AffineGraphQL.DateTime.self),
] }
public var id: AffineGraphQL.ID? { __data["id"] }
public var role: String { __data["role"] }
public var content: String { __data["content"] }
public var attachments: [String]? { __data["attachments"] }
public var params: AffineGraphQL.JSON? { __data["params"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
}
}
}
}
}
}

View File

@@ -0,0 +1,108 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class GetCopilotRecentSessionsQuery: GraphQLQuery {
public static let operationName: String = "getCopilotRecentSessions"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query getCopilotRecentSessions($workspaceId: String!, $limit: Int = 10) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename histories(options: { limit: $limit, sessionOrder: desc }) { __typename sessionId workspaceId docId pinned action tokens createdAt updatedAt } } } }"#
))
public var workspaceId: String
public var limit: GraphQLNullable<Int>
public init(
workspaceId: String,
limit: GraphQLNullable<Int> = 10
) {
self.workspaceId = workspaceId
self.limit = limit
}
public var __variables: Variables? { [
"workspaceId": workspaceId,
"limit": limit
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("currentUser", CurrentUser?.self),
] }
/// Get current user
public var currentUser: CurrentUser? { __data["currentUser"] }
/// CurrentUser
///
/// Parent Type: `UserType`
public struct CurrentUser: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.UserType }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("copilot", Copilot.self, arguments: ["workspaceId": .variable("workspaceId")]),
] }
public var copilot: Copilot { __data["copilot"] }
/// CurrentUser.Copilot
///
/// Parent Type: `Copilot`
public struct Copilot: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Copilot }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("histories", [History].self, arguments: ["options": [
"limit": .variable("limit"),
"sessionOrder": "desc"
]]),
] }
public var histories: [History] { __data["histories"] }
/// CurrentUser.Copilot.History
///
/// Parent Type: `CopilotHistories`
public struct History: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.CopilotHistories }
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("sessionId", String.self),
.field("workspaceId", String.self),
.field("docId", String?.self),
.field("pinned", Bool.self),
.field("action", String?.self),
.field("tokens", Int.self),
.field("createdAt", AffineGraphQL.DateTime.self),
.field("updatedAt", AffineGraphQL.DateTime.self),
] }
public var sessionId: String { __data["sessionId"] }
public var workspaceId: String { __data["workspaceId"] }
public var docId: String? { __data["docId"] }
public var pinned: Bool { __data["pinned"] }
/// An mark identifying which view to use to display the session
public var action: String? { __data["action"] }
/// The number of tokens used in the session
public var tokens: Int { __data["tokens"] }
public var createdAt: AffineGraphQL.DateTime { __data["createdAt"] }
public var updatedAt: AffineGraphQL.DateTime { __data["updatedAt"] }
}
}
}
}
}

View File

@@ -19,6 +19,7 @@ let package = Package(
.package(url: "https://github.com/devxoul/Then", from: "3.0.0"),
.package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.1"),
.package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.0.0"),
.package(url: "https://github.com/loopwork-ai/eventsource.git", from: "1.1.1"),
],
targets: [
.target(name: "Intelligents", dependencies: [
@@ -28,6 +29,7 @@ let package = Package(
"SwifterSwift",
.product(name: "Apollo", package: "apollo-ios"),
.product(name: "OrderedCollections", package: "swift-collections"),
.product(name: "EventSource", package: "eventsource"),
], resources: [
.process("Interface/View/InputBox/InputBox.xcassets"),
.process("Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets"),

View File

@@ -0,0 +1,31 @@
//
// ChatError.swift
// Intelligents
//
// Created by on 6/30/25.
//
import Foundation
public enum ChatError: LocalizedError {
case invalidServerConfiguration
case invalidStreamURL
case invalidResponse
case networkError(Error)
case unknownError
public var errorDescription: String? {
switch self {
case .invalidServerConfiguration:
"Invalid server configuration"
case .invalidStreamURL:
"Invalid stream URL"
case .invalidResponse:
"Invalid response from server"
case let .networkError(error):
"Network error: \(error.localizedDescription)"
case .unknownError:
"An unknown error occurred"
}
}
}

View File

@@ -0,0 +1,13 @@
//
// ChatManager+Closable.swift
// Intelligents
//
// Created by on 6/30/25.
//
import EventSource
import Foundation
protocol Closable { func close() }
extension EventSource: @preconcurrency Closable {}

View File

@@ -1,125 +0,0 @@
//
// ChatManager+ContextModels.swift
// Intelligents
//
// Created by on 6/26/25.
//
import AffineGraphQL
import Foundation
// MARK: - ChatManager Context Models Extension
extension ChatManager {
// MARK: - Context Models
struct ContextReference: Codable, Identifiable, Equatable, Hashable {
var id: String
var fileId: String?
var docId: String?
var chunk: Int
var content: String
var distance: Double
var highlightedContent: String?
init(fileId: String? = nil, docId: String? = nil, chunk: Int, content: String, distance: Double, highlightedContent: String? = nil) {
id = UUID().uuidString
self.fileId = fileId
self.docId = docId
self.chunk = chunk
self.content = content
self.distance = distance
self.highlightedContent = highlightedContent
}
}
struct CopilotContext: Codable, Identifiable, Equatable, Hashable {
var id: String
var sessionId: String
var workspaceId: String
var files: [ContextFile]
var docs: [ContextDoc]
var categories: [ContextCategory]
init(id: String, sessionId: String, workspaceId: String, files: [ContextFile] = [], docs: [ContextDoc] = [], categories: [ContextCategory] = []) {
self.id = id
self.sessionId = sessionId
self.workspaceId = workspaceId
self.files = files
self.docs = docs
self.categories = categories
}
}
struct ContextFile: Codable, Identifiable, Equatable, Hashable {
var id: String
var contextId: String
var blobId: String
var fileName: String?
var fileSize: Int?
var mimeType: String?
var embeddingStatus: ContextEmbedStatus?
var createdAt: DateTime?
var createdDate: Date? {
createdAt?.decoded
}
}
struct ContextDoc: Codable, Identifiable, Equatable, Hashable {
var id: String
var contextId: String
var docId: String
var title: String?
var embeddingStatus: ContextEmbedStatus?
var createdAt: DateTime?
var createdDate: Date? {
createdAt?.decoded
}
}
struct ContextCategory: Codable, Identifiable, Equatable, Hashable {
var id: String
var contextId: String
var type: ContextCategoryType
var docs: [String]
var name: String?
var createdAt: DateTime?
var createdDate: Date? {
createdAt?.decoded
}
}
enum ContextEmbedStatus: String, Codable, CaseIterable {
case pending = "Pending"
case failed = "Failed"
case completed = "Completed"
}
enum ContextCategoryType: String, Codable, CaseIterable {
case tag = "TAG"
case collection = "COLLECTION"
}
struct MatchContextResult: Codable, Identifiable, Equatable, Hashable {
var id: String
var fileId: String?
var docId: String?
var chunk: Int
var content: String
var distance: Double
var highlightedContent: String?
init(fileId: String? = nil, docId: String? = nil, chunk: Int, content: String, distance: Double, highlightedContent: String? = nil) {
id = UUID().uuidString
self.fileId = fileId
self.docId = docId
self.chunk = chunk
self.content = content
self.distance = distance
self.highlightedContent = highlightedContent
}
}
}

View File

@@ -1,44 +0,0 @@
//
// ChatManager+InputModels.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
// MARK: - ChatManager Input Models Extension
extension ChatManager {
// MARK: - Input Models
struct AddContextFileInput: Codable, Equatable, Hashable {
var contextId: String
var blobId: String
}
struct RemoveContextFileInput: Codable, Equatable, Hashable {
var contextId: String
var fileId: String
}
struct AddContextDocInput: Codable, Equatable, Hashable {
var contextId: String
var docId: String
}
struct RemoveContextDocInput: Codable, Equatable, Hashable {
var contextId: String
var docId: String
}
struct AddContextCategoryInput: Codable, Equatable, Hashable {
var contextId: String
var docs: [String]
}
struct RemoveContextCategoryInput: Codable, Equatable, Hashable {
var contextId: String
var categoryId: String
}
}

View File

@@ -0,0 +1,138 @@
//
// ChatManager+Stream.swift
// Intelligents
//
// Created by on 6/30/25.
//
import AffineGraphQL
import Apollo
import ApolloAPI
import EventSource
import Foundation
extension ChatManager {
public func startUserRequest(
content: String,
inputBoxData: InputBoxData,
sessionId: String
) {
append(sessionId: sessionId, UserMessageCellViewModel(
id: .init(),
content: inputBoxData.text,
timestamp: .init(),
attachments: []
))
let messageParameters: [String: AnyHashable] = [
// packages/frontend/core/src/blocksuite/ai/provider/setup-provider.tsx
"docs": inputBoxData.documentAttachments.map(\.documentID), // affine doc
"files": [String](), // attachment in context, keep nil for now
"searchMode": inputBoxData.isSearchEnabled ? "MUST" : "AUTO",
]
let uploadableAttachments: [GraphQLFile] = [
inputBoxData.fileAttachments.map { file -> GraphQLFile in
.init(
fieldName: file.name,
originalName: file.name,
data: file.data ?? .init()
)
},
inputBoxData.imageAttachments.map { image -> GraphQLFile in
.init(
fieldName: image.hashValue.description,
originalName: "image.jpg",
data: image.imageData
)
},
].flatMap(\.self)
assert(uploadableAttachments.allSatisfy { !($0.data?.isEmpty ?? true) })
guard let input = try? CreateChatMessageInput(
content: .some(content),
params: .some(AffineGraphQL.JSON(_jsonValue: messageParameters)),
sessionId: sessionId
) else {
assertionFailure() // very unlikely to happen
return
}
let mutation = CreateCopilotMessageMutation(options: input)
QLService.shared.client.upload(operation: mutation, files: uploadableAttachments) { result in
DispatchQueue.main.async {
switch result {
case let .success(graphQLResult):
guard let messageIdentifier = graphQLResult.data?.createCopilotMessage else {
self.report(sessionId, ChatError.invalidResponse)
return
}
let viewModelId = self.append(sessionId: sessionId, AssistantMessageCellViewModel(
id: .init(),
content: .init(),
timestamp: .init()
))
self.startStreamingResponse(
sessionId: sessionId,
messageId: messageIdentifier,
applyingTo: viewModelId
)
case let .failure(error):
self.report(sessionId, error)
}
}
}
}
private func startStreamingResponse(sessionId: String, messageId: String, applyingTo vmId: UUID) {
let base = IntelligentContext.shared.webViewMetadata[.currentServerBaseUrl] as? String
guard let base, let url = URL(string: base) else {
report(sessionId, ChatError.invalidServerConfiguration)
return
}
let streamUrl = url
.appendingPathComponent("api")
.appendingPathComponent("copilot")
.appendingPathComponent("chat")
.appendingPathComponent(sessionId)
.appendingPathComponent("stream")
var comps = URLComponents(url: streamUrl, resolvingAgainstBaseURL: false)
comps?.queryItems = [
.init(name: "messageId", value: messageId),
.init(name: "retry", value: "false"), // TODO: IMPL FROM UI
]
guard let finalUrl = comps?.url else {
report(sessionId, ChatError.invalidStreamURL)
return
}
let eventSource = EventSource(
request: .init(
url: finalUrl,
cachePolicy: .reloadIgnoringLocalAndRemoteCacheData,
timeoutInterval: 10
),
configuration: .default
)
eventSource.onOpen = {
print("[*] \(messageId): connection established")
}
eventSource.onError = {
self.report(sessionId, $0 ?? ChatError.unknownError)
}
var document = ""
let queue = DispatchQueue(label: "com.affine.chat.stream.\(sessionId)")
eventSource.onMessage = { event in
queue.async {
print("[*] \(messageId): \(event.event ?? "?") received message: \(event.data)")
switch event.event {
case "message":
document += event.data
self.with(sessionId: sessionId, vmId: vmId) { (viewModel: inout AssistantMessageCellViewModel) in
viewModel.content = document
}
default:
break
}
}
}
closable.append(eventSource)
}
}

View File

@@ -1,74 +0,0 @@
//
// ChatManager+WorkflowModels.swift
// Intelligents
//
// Created by on 6/26/25.
//
import AffineGraphQL
import Foundation
// MARK: - ChatManager Workflow Models Extension
extension ChatManager {
// MARK: - Workflow Models
struct WorkflowEventData: Codable, Identifiable, Equatable, Hashable {
var id: String
var status: String
var type: String
var progress: Double?
var message: String?
init(status: String, type: String, progress: Double? = nil, message: String? = nil) {
id = UUID().uuidString
self.status = status
self.type = type
self.progress = progress
self.message = message
}
}
struct WorkspaceEmbeddingStatus: Codable, Identifiable, Equatable, Hashable {
var id: String
var workspaceId: String
var total: Int
var embedded: Int
var progress: Double {
total > 0 ? Double(embedded) / Double(total) : 0.0
}
init(workspaceId: String, total: Int, embedded: Int) {
id = workspaceId
self.workspaceId = workspaceId
self.total = total
self.embedded = embedded
}
}
struct ChatEvent: Codable, Identifiable, Equatable, Hashable {
var id: String
var type: ChatEventType
var data: String
var timestamp: DateTime?
var timestampDate: Date? {
timestamp?.decoded
}
init(type: ChatEventType, data: String, timestamp: DateTime? = nil) {
id = UUID().uuidString
self.type = type
self.data = data
self.timestamp = timestamp
}
}
enum ChatEventType: String, Codable, CaseIterable {
case message
case attachment
case event
case ping
}
}

View File

@@ -7,221 +7,76 @@
import AffineGraphQL
import Apollo
import ApolloAPI
import Combine
import EventSource
import Foundation
import OrderedCollections
// MARK: - ChatManager
public class ChatManager: ObservableObject {
public class ChatManager: ObservableObject, @unchecked Sendable {
public static let shared = ChatManager()
// MARK: - Properties
public typealias SessionID = String
public typealias MessageID = UUID // ChatCellViewModel ID
@Published public private(set) var viewModels: OrderedDictionary<
SessionID,
OrderedDictionary<MessageID, any ChatCellViewModel>
> = [:]
@Published public private(set) var sessions: [SessionViewModel] = []
@Published public private(set) var currentSession: SessionViewModel?
@Published public private(set) var messages: [String: [ChatMessage]] = [:]
@Published public private(set) var isLoading = false
@Published public private(set) var error: Error?
var closable: [Closable] = []
private var cancellables = Set<AnyCancellable>()
private let apolloClient: ApolloClient
private init() {}
// MARK: - Initialization
private init(apolloClient: ApolloClient = QLService.shared.client) {
self.apolloClient = apolloClient
public func closeAll() {
closable.forEach { $0.close() }
closable.removeAll()
}
// MARK: - Public Methods
public func with(sessionId: String, _ action: (inout OrderedDictionary<MessageID, any ChatCellViewModel>) -> Void) {
if Thread.isMainThread {
if var sessionViewModels = viewModels[sessionId] {
action(&sessionViewModels)
viewModels[sessionId] = sessionViewModels
} else {
var sessionViewModels = OrderedDictionary<MessageID, any ChatCellViewModel>()
action(&sessionViewModels)
viewModels[sessionId] = sessionViewModels
}
} else {
DispatchQueue.main.asyncAndWait {
self.with(sessionId: sessionId, action)
}
}
}
public func createSession(
workspaceId: String,
promptName: String = "",
docId: String? = nil,
pinned: Bool = false
) async throws -> SessionViewModel {
isLoading = true
error = nil
do {
let input = CreateChatSessionInput(
docId: docId.map { .some($0) } ?? .null,
pinned: .some(pinned),
promptName: promptName,
workspaceId: workspaceId
)
let mutation = CreateCopilotSessionMutation(options: input)
return try await withCheckedThrowingContinuation { continuation in
apolloClient.perform(mutation: mutation) { result in
switch result {
case let .success(graphQLResult):
guard let sessionId = graphQLResult.data?.createCopilotSession else {
continuation.resume(throwing: ChatError.invalidResponse)
return
}
let session = SessionViewModel(
id: sessionId,
workspaceId: workspaceId,
docId: docId,
promptName: promptName,
model: nil,
pinned: pinned,
tokens: 0,
createdAt: DateTime(date: Date()),
updatedAt: DateTime(date: Date()),
parentSessionId: nil
)
Task { @MainActor in
self.sessions.append(session)
self.currentSession = session
self.messages[sessionId] = []
self.isLoading = false
}
continuation.resume(returning: session)
case let .failure(error):
Task { @MainActor in
self.error = error
self.isLoading = false
}
continuation.resume(throwing: error)
}
public func with<T>(sessionId: String, vmId: UUID, _ action: (inout T) -> Void) {
with(sessionId: sessionId) { sessionViewModels in
if let read = sessionViewModels[vmId], var convert = read as? T {
action(&convert)
guard let vm = convert as? any ChatCellViewModel else {
assertionFailure()
return
}
sessionViewModels[vmId] = vm
} else {
assertionFailure()
}
} catch {
await MainActor.run {
self.error = error
self.isLoading = false
}
throw error
}
}
public func sendMessage(
content: String,
attachments: [String] = [],
sessionId: String? = nil
) async throws {
guard let targetSessionId = sessionId ?? currentSession?.id else {
throw ChatError.noActiveSession
}
@discardableResult
public func append(sessionId: String, _ viewModel: any ChatCellViewModel) -> UUID {
with(sessionId: sessionId) { $0.updateValue(viewModel, forKey: viewModel.id) }
return viewModel.id
}
isLoading = true
error = nil
// Add user message immediately
let userMessage = ChatMessage(
id: UUID().uuidString,
role: .user,
content: content,
attachments: attachments.isEmpty ? nil : attachments,
params: nil,
createdAt: DateTime(date: Date())
@discardableResult
public func report(_ sessionID: String, _ error: Error) -> UUID {
let model = ErrorCellViewModel(
id: .init(),
errorMessage: error.localizedDescription
)
await MainActor.run {
var sessionMessages = self.messages[targetSessionId] ?? []
sessionMessages.append(userMessage)
self.messages[targetSessionId] = sessionMessages
}
do {
let input = CreateChatMessageInput(
attachments: attachments.isEmpty ? .null : .some(attachments),
blobs: .null,
content: .some(content),
params: .null,
sessionId: targetSessionId
)
let mutation = CreateCopilotMessageMutation(options: input)
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
apolloClient.perform(mutation: mutation) { result in
switch result {
case let .success(graphQLResult):
guard let messageId = graphQLResult.data?.createCopilotMessage else {
continuation.resume(throwing: ChatError.invalidResponse)
return
}
// Add assistant message placeholder
let assistantMessage = ChatMessage(
id: messageId,
role: .assistant,
content: "Thinking...",
attachments: nil,
params: nil,
createdAt: DateTime(date: Date())
)
Task { @MainActor in
var sessionMessages = self.messages[targetSessionId] ?? []
sessionMessages.append(assistantMessage)
self.messages[targetSessionId] = sessionMessages
self.isLoading = false
}
continuation.resume()
// TODO: Implement streaming response handling
case let .failure(error):
Task { @MainActor in
self.error = error
self.isLoading = false
}
continuation.resume(throwing: error)
}
}
}
} catch {
await MainActor.run {
self.error = error
self.isLoading = false
}
throw error
}
}
public func switchToSession(_ session: SessionViewModel) {
currentSession = session
}
public func deleteSession(sessionId: String) {
sessions.removeAll { $0.id == sessionId }
messages.removeValue(forKey: sessionId)
if currentSession?.id == sessionId {
currentSession = sessions.first
}
}
public func clearError() {
error = nil
}
}
// MARK: - ChatError
public enum ChatError: LocalizedError {
case noActiveSession
case invalidResponse
case networkError(Error)
public var errorDescription: String? {
switch self {
case .noActiveSession:
"No active chat session"
case .invalidResponse:
"Invalid response from server"
case let .networkError(error):
"Network error: \(error.localizedDescription)"
}
append(sessionId: sessionID, model)
return model.id
}
}

View File

@@ -1,5 +1,5 @@
//
// ChatMessage.swift
// ChatSessionObject.swift
// Intelligents
//
// Created by on 6/26/25.
@@ -8,48 +8,7 @@
import AffineGraphQL
import Foundation
public struct ChatMessage: Codable, Identifiable, Equatable, Hashable {
public var id: String?
public var role: MessageRole
public var content: String
public var attachments: [String]?
public var params: [String: String]?
public var createdAt: DateTime?
public var createdDate: Date? {
createdAt?.decoded
}
public var messageId: String {
id ?? UUID().uuidString
}
public init(
id: String? = nil,
role: MessageRole,
content: String,
attachments: [String]? = nil,
params: [String: String]? = nil,
createdAt: DateTime? = nil
) {
self.id = id
self.role = role
self.content = content
self.attachments = attachments
self.params = params
self.createdAt = createdAt
}
}
public extension ChatMessage {
enum MessageRole: String, Codable, CaseIterable {
case user
case assistant
case system
}
}
public struct SessionViewModel: Codable, Identifiable, Equatable, Hashable {
public struct ChatSessionObject: Codable, Identifiable, Equatable, Hashable {
public var id: String
public var workspaceId: String
public var docId: String?

View File

@@ -0,0 +1,29 @@
//
// PromptName.swift
// Intelligents
//
// Created by on 6/30/25.
//
import Foundation
public enum PromptName: String, Codable {
case summary = "Summary"
case summaryAsTitle = "Summary as title"
case explainThis = "Explain this"
case writeAnArticleAboutThis = "Write an article about this"
case writeATwitterAboutThis = "Write a twitter about this"
case writeAPoemAboutThis = "Write a poem about this"
case writeABlogPostAboutThis = "Write a blog post about this"
case writeOutline = "Write outline"
case changeToneTo = "Change tone to"
case improveWritingForIt = "Improve writing for it"
case improveGrammarForIt = "Improve grammar for it"
case fixSpellingForIt = "Fix spelling for it"
case createHeadings = "Create headings"
case makeItLonger = "Make it longer"
case makeItShorter = "Make it shorter"
case continueWriting = "Continue writing"
case chatWithAffineAI = "Chat With AFFiNE AI"
case searchWithAffineAI = "Search With AFFiNE AI"
}

View File

@@ -0,0 +1,57 @@
//
// IntelligentContext+CreateSession.swift
// Intelligents
//
// Created by on 6/27/25.
//
import AffineGraphQL
import Apollo
import ApolloAPI
import Foundation
public extension IntelligentContext {
func createSession(
workspaceId: String,
promptName: PromptName = .chatWithAffineAI,
docId: String? = nil,
pinned: Bool = false,
completion: @escaping (Result<ChatSessionObject, Error>) -> Void
) {
let input = CreateChatSessionInput(
docId: docId.map { .some($0) } ?? .null,
pinned: .some(pinned),
promptName: promptName.rawValue,
workspaceId: workspaceId
)
let mutation = CreateCopilotSessionMutation(options: input)
QLService.shared.client.perform(mutation: mutation) { result in
switch result {
case let .success(graphQLResult):
guard let sessionId = graphQLResult.data?.createCopilotSession else {
completion(.failure(IntelligentError.sessionCreationFailed("No session ID returned.")))
return
}
let session = ChatSessionObject(
id: sessionId,
workspaceId: workspaceId,
docId: docId,
promptName: promptName.rawValue,
model: nil,
pinned: pinned,
tokens: 0,
createdAt: DateTime(date: Date()),
updatedAt: DateTime(date: Date()),
parentSessionId: nil
)
completion(.success(session))
case let .failure(error):
completion(.failure(IntelligentError.sessionCreationFailed(error.localizedDescription)))
}
}
}
}

View File

@@ -42,6 +42,9 @@ public class IntelligentContext {
case currentI18nLocale
}
public private(set) var currentSession: ChatSessionObject?
public private(set) var currentWorkspaceId: String?
public lazy var temporaryDirectory: URL = {
let tempDir = FileManager.default.temporaryDirectory
return tempDir.appendingPathComponent("IntelligentContext")
@@ -49,11 +52,14 @@ public class IntelligentContext {
public enum IntelligentError: Error, LocalizedError {
case loginRequired(String)
case sessionCreationFailed(String)
public var errorDescription: String? {
switch self {
case let .loginRequired(reason):
"Login required: \(reason)"
case let .sessionCreationFailed(reason):
"Session creation failed: \(reason)"
}
}
}
@@ -61,6 +67,7 @@ public class IntelligentContext {
private init() {}
public func preparePresent(_ completion: @escaping (Result<Void, Error>) -> Void) {
assert(webView != nil)
DispatchQueue.global(qos: .userInitiated).async { [self] in
prepareTemporaryDirectory()
@@ -79,21 +86,18 @@ public class IntelligentContext {
!baseUrlString.isEmpty,
let url = URL(string: baseUrlString)
else {
DispatchQueue.main.async {
completion(.failure(IntelligentError.loginRequired("Missing server base URL")))
}
completion(.failure(IntelligentError.loginRequired("Missing server base URL")))
return
}
guard let workspaceId = webViewMetadataResult[.currentWorkspaceId] as? String,
!workspaceId.isEmpty
else {
DispatchQueue.main.async {
completion(.failure(IntelligentError.loginRequired("Missing workspace ID")))
}
completion(.failure(IntelligentError.loginRequired("Missing workspace ID")))
return
}
currentWorkspaceId = workspaceId
QLService.shared.setEndpoint(base: url)
let gqlGroup = DispatchGroup()
@@ -106,20 +110,28 @@ public class IntelligentContext {
gqlGroup.wait()
qlMetadata = gqlMetadataResult
// Check required QL metadata
guard let userIdentifier = gqlMetadataResult[.userIdentifierKey] as? String,
!userIdentifier.isEmpty
else {
DispatchQueue.main.async {
completion(.failure(IntelligentError.loginRequired("Missing user identifier")))
}
completion(.failure(IntelligentError.loginRequired("Missing user identifier")))
return
}
let currentDocumentId: String? = webViewMetadata[.currentDocId] as? String
dumpMetadataContents()
DispatchQueue.main.async {
completion(.success(()))
createSession(
workspaceId: workspaceId,
docId: currentDocumentId
) { result in
switch result {
case let .success(session):
self.currentSession = session
completion(.success(()))
case let .failure(error):
completion(.failure(error))
}
}
}
}

View File

@@ -45,7 +45,7 @@ private class _AttachmentManagementController: UIViewController {
Section,
Item
> = .init(tableView: tableView) { [weak self] tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(withIdentifier: "AttachmentCell", for: indexPath) as! AttachmentCell
let cell = tableView.dequeueReusableCell(withIdentifier: "AttachmentManagementCell", for: indexPath) as! AttachmentManagementCell
cell.configure(with: item)
cell.onDelete = { [weak self] in
guard let delegateController = self?.delegateController else { return }
@@ -119,7 +119,7 @@ private class _AttachmentManagementController: UIViewController {
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.register(AttachmentCell.self, forCellReuseIdentifier: "AttachmentCell")
tableView.register(AttachmentManagementCell.self, forCellReuseIdentifier: "AttachmentManagementCell")
tableView.clipsToBounds = true
tableView.rowHeight = UITableView.automaticDimension
view.addSubview(tableView)
@@ -156,7 +156,7 @@ private class _AttachmentManagementController: UIViewController {
}
}
private class AttachmentCell: UITableViewCell {
private class AttachmentManagementCell: UITableViewCell {
let container = UIView().then {
$0.layer.cornerRadius = 4
$0.layer.borderWidth = 0.5

View File

@@ -76,41 +76,20 @@ extension MainViewController: InputBoxDelegate {
func inputBoxDidSend(_ inputBox: InputBox) {
let inputData = inputBox.inputBoxData
inputBox.text = ""
inputBox.viewModel.clearAllAttachments()
Task { @MainActor in
do {
let chatManager = ChatManager.shared
if let currentSession = chatManager.currentSession {
try await chatManager.sendMessage(
content: inputData.text,
attachments: [], // TODO: Handle attachments
sessionId: currentSession.id
)
} else {
guard let workspaceId = IntelligentContext.shared.webViewMetadata[.currentWorkspaceId] as? String,
!workspaceId.isEmpty
else {
showAlert(title: "Error", message: "No workspace available")
return
}
let session = try await chatManager.createSession(workspaceId: workspaceId)
try await chatManager.sendMessage(
content: inputData.text,
attachments: [], // TODO: Handle attachments
sessionId: session.id
)
}
inputBox.text = ""
inputBox.viewModel.clearAllAttachments()
} catch {
showAlert(title: "Error", message: error.localizedDescription)
}
guard let currentSession = IntelligentContext.shared.currentSession else {
showAlert(title: "Error", message: "No active session available")
return
}
ChatManager.shared.closeAll()
ChatManager.shared.startUserRequest(
content: inputData.text,
inputBoxData: inputData,
sessionId: currentSession.id
)
}
private func showAlert(title: String, message: String) {

View File

@@ -10,25 +10,8 @@ class MainViewController: UIViewController {
$0.delegate = self
}
lazy var tableView = UITableView().then {
$0.backgroundColor = .clear
$0.separatorStyle = .none
lazy var chatTableView = ChatTableView().then {
$0.delegate = self
$0.dataSource = self
$0.register(ChatCell.self, forCellReuseIdentifier: "ChatCell")
$0.keyboardDismissMode = .interactive
$0.contentInsetAdjustmentBehavior = .never
}
lazy var emptyStateView = UIView().then {
$0.isHidden = true
}
lazy var emptyStateLabel = UILabel().then {
$0.text = "Start a conversation..."
$0.font = .systemFont(ofSize: 18, weight: .medium)
$0.textColor = .systemGray
$0.textAlignment = .center
}
lazy var inputBox = InputBox().then {
@@ -49,10 +32,9 @@ class MainViewController: UIViewController {
// MARK: - Properties
private var messages: [ChatMessage] = []
private var cancellables = Set<AnyCancellable>()
private let intelligentContext = IntelligentContext.shared
private let chatManager = ChatManager.shared
var cancellables = Set<AnyCancellable>()
let intelligentContext = IntelligentContext.shared
let chatManager = ChatManager.shared
var terminateEditGesture: UITapGestureRecognizer!
// MARK: - Lifecycle
@@ -62,7 +44,6 @@ class MainViewController: UIViewController {
view.backgroundColor = .affineLayerBackgroundPrimary
setupUI()
setupBindings()
view.isUserInteractionEnabled = true
terminateEditGesture = UITapGestureRecognizer(target: self, action: #selector(terminateEditing))
@@ -73,32 +54,20 @@ class MainViewController: UIViewController {
private func setupUI() {
view.addSubview(headerView)
view.addSubview(tableView)
view.addSubview(emptyStateView)
view.addSubview(chatTableView)
view.addSubview(inputBox)
view.addSubview(documentPickerHideDetector)
view.addSubview(documentPickerView)
emptyStateView.addSubview(emptyStateLabel)
headerView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
}
tableView.snp.makeConstraints { make in
chatTableView.snp.makeConstraints { make in
make.top.equalTo(headerView.snp.bottom)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(inputBox.snp.top)
}
emptyStateView.snp.makeConstraints { make in
make.center.equalTo(tableView)
make.width.lessThanOrEqualTo(tableView).inset(32)
}
emptyStateLabel.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.left.right.equalToSuperview()
make.bottom.equalToSuperview()
}
inputBox.snp.makeConstraints { make in
@@ -117,24 +86,6 @@ class MainViewController: UIViewController {
}
}
private func setupBindings() {
chatManager.$currentSession
.receive(on: DispatchQueue.main)
.sink { [weak self] session in
self?.updateMessages(for: session?.id)
}
.store(in: &cancellables)
chatManager.$messages
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
if let sessionId = self?.chatManager.currentSession?.id {
self?.updateMessages(for: sessionId)
}
}
.store(in: &cancellables)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController!.setNavigationBarHidden(true, animated: animated)
@@ -154,64 +105,12 @@ class MainViewController: UIViewController {
}
// MARK: - Chat Methods
private func updateMessages(for sessionId: String?) {
guard let sessionId else {
messages = []
updateEmptyState()
tableView.reloadData()
return
}
messages = chatManager.messages[sessionId] ?? []
updateEmptyState()
tableView.reloadData()
if !messages.isEmpty {
let indexPath = IndexPath(row: messages.count - 1, section: 0)
tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
}
}
private func updateEmptyState() {
emptyStateView.isHidden = !messages.isEmpty
tableView.isHidden = messages.isEmpty
}
// MARK: - Internal Methods for Preview/Testing
#if DEBUG
func setMessagesForPreview(_ previewMessages: [ChatMessage]) {
messages = previewMessages
updateEmptyState()
tableView.reloadData()
}
#endif
}
// MARK: - UITableViewDataSource
// MARK: - ChatTableViewDelegate
extension MainViewController: UITableViewDataSource {
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
messages.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "ChatCell", for: indexPath) as! ChatCell
let message = messages[indexPath.row]
cell.configure(with: message)
return cell
}
}
// MARK: - UITableViewDelegate
extension MainViewController: UITableViewDelegate {
func tableView(_: UITableView, heightForRowAt _: IndexPath) -> CGFloat {
UITableView.automaticDimension
}
func tableView(_: UITableView, estimatedHeightForRowAt _: IndexPath) -> CGFloat {
60
extension MainViewController: ChatTableViewDelegate {
func chatTableView(_: ChatTableView, didSelectRowAt _: IndexPath) {
// Handle cell selection if needed
}
}

View File

@@ -0,0 +1,139 @@
//
// AssistantMessageCell.swift
// Intelligents
//
// Created by on 6/27/25.
//
import SnapKit
import Then
import UIKit
class AssistantMessageCell: ChatBaseCell {
// MARK: - UI Components
private lazy var messageLabel = UILabel().then {
$0.numberOfLines = 0
$0.font = .systemFont(ofSize: 16)
$0.textColor = .label
}
private lazy var metadataStackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 8
$0.alignment = .center
}
private lazy var modelLabel = UILabel().then {
$0.font = .systemFont(ofSize: 12, weight: .medium)
$0.textColor = .secondaryLabel
}
private lazy var tokensLabel = UILabel().then {
$0.font = .systemFont(ofSize: 12)
$0.textColor = .secondaryLabel
}
private lazy var timestampLabel = UILabel().then {
$0.font = .systemFont(ofSize: 12)
$0.textColor = .secondaryLabel
}
private lazy var streamingIndicator = UIActivityIndicatorView().then {
$0.style = .medium
$0.hidesWhenStopped = true
}
private lazy var retryButton = UIButton(type: .system).then {
$0.setTitle("重试", for: .normal)
$0.titleLabel?.font = .systemFont(ofSize: 12)
$0.setTitleColor(.systemBlue, for: .normal)
}
private lazy var mainStackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 8
$0.alignment = .fill
}
// MARK: - Properties
private var viewModel: AssistantMessageCellViewModel?
// MARK: - Setup
override func setupContentView() {
containerView.addSubview(mainStackView)
mainStackView.addArrangedSubview(messageLabel)
mainStackView.addArrangedSubview(metadataStackView)
metadataStackView.addArrangedSubview(modelLabel)
metadataStackView.addArrangedSubview(tokensLabel)
metadataStackView.addArrangedSubview(UIView()) // Spacer
metadataStackView.addArrangedSubview(streamingIndicator)
metadataStackView.addArrangedSubview(retryButton)
metadataStackView.addArrangedSubview(timestampLabel)
mainStackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(contentInsets)
}
retryButton.addTarget(self, action: #selector(retryButtonTapped), for: .touchUpInside)
}
// MARK: - Configuration
override func configure(with viewModel: any ChatCellViewModel) {
guard let assistantViewModel = viewModel as? AssistantMessageCellViewModel else { return }
self.viewModel = assistantViewModel
messageLabel.text = assistantViewModel.content
configureContainer(backgroundColor: backgroundColor(for: assistantViewModel.cellType))
//
if let model = assistantViewModel.model {
modelLabel.text = model
modelLabel.isHidden = false
} else {
modelLabel.isHidden = true
}
// tokens
if let tokens = assistantViewModel.tokens {
tokensLabel.text = "\(tokens) tokens"
tokensLabel.isHidden = false
} else {
tokensLabel.isHidden = true
}
//
let timestamp = assistantViewModel.timestamp
timestampLabel.text = formatTimestamp(timestamp)
timestampLabel.isHidden = false
//
if assistantViewModel.isStreaming {
streamingIndicator.startAnimating()
} else {
streamingIndicator.stopAnimating()
}
//
retryButton.isHidden = !assistantViewModel.canRetry
}
// MARK: - Actions
@objc private func retryButtonTapped() {
// TODO:
}
// MARK: - Helpers
private func formatTimestamp(_ timestamp: Date) -> String {
let formatter = DateFormatter()
formatter.timeStyle = .short
return formatter.string(from: timestamp)
}
}

View File

@@ -0,0 +1,110 @@
//
// ChatBaseCell.swift
// Intelligents
//
// Created by on 6/27/25.
//
import SnapKit
import Then
import UIKit
class ChatBaseCell: UITableViewCell {
// MARK: - UI Components
///
lazy var containerView = UIView().then {
$0.layer.cornerRadius = 8
$0.layer.cornerCurve = .continuous
}
// MARK: - Properties
///
var containerInsets: UIEdgeInsets {
UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
}
///
var contentInsets: UIEdgeInsets {
UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
}
// MARK: - Initialization
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupBaseUI()
setupContentView()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Setup
private func setupBaseUI() {
backgroundColor = .clear
selectionStyle = .none
contentView.addSubview(containerView)
containerView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(containerInsets)
}
}
///
func setupContentView() {
//
}
// MARK: - Configuration
///
func configureContainer(backgroundColor: UIColor?, borderColor: UIColor? = nil, borderWidth: CGFloat = 0) {
containerView.backgroundColor = backgroundColor
if let borderColor {
containerView.layer.borderColor = borderColor.cgColor
containerView.layer.borderWidth = borderWidth
} else {
containerView.layer.borderColor = nil
containerView.layer.borderWidth = 0
}
}
/// ViewModel
func configure(with _: any ChatCellViewModel) {
//
}
// MARK: - Helpers
///
func textColor(for cellType: CellType) -> UIColor {
switch cellType {
case .userMessage, .assistantMessage, .systemMessage:
.label
case .error:
.systemRed
case .loading:
.secondaryLabel
}
}
///
func backgroundColor(for cellType: CellType) -> UIColor? {
switch cellType {
case .userMessage, .assistantMessage:
.clear
case .systemMessage:
.systemYellow.withAlphaComponent(0.2)
case .error:
.systemRed.withAlphaComponent(0.1)
case .loading:
.systemGray6
}
}
}

View File

@@ -1,155 +0,0 @@
//
// ChatCell.swift
// Intelligents
//
// Created by on 6/26/25.
//
import SnapKit
import Then
import UIKit
class ChatCell: UITableViewCell {
// MARK: - UI Components
private lazy var avatarImageView = UIImageView().then {
$0.contentMode = .scaleAspectFit
$0.layer.cornerRadius = 16
$0.layer.cornerCurve = .continuous
$0.clipsToBounds = true
$0.backgroundColor = .systemGray5
}
private lazy var messageContainerView = UIView().then {
$0.layer.cornerRadius = 12
$0.layer.cornerCurve = .continuous
}
private lazy var messageLabel = UILabel().then {
$0.numberOfLines = 0
$0.font = .systemFont(ofSize: 16)
$0.textColor = .label
}
private lazy var timestampLabel = UILabel().then {
$0.font = .systemFont(ofSize: 12)
$0.textColor = .systemGray
$0.textAlignment = .right
}
private lazy var stackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 12
$0.alignment = .top
}
private lazy var messageStackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 4
}
// MARK: - Properties
private var message: ChatMessage?
// MARK: - Initialization
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Setup
private func setupUI() {
backgroundColor = .clear
selectionStyle = .none
contentView.addSubview(stackView)
messageStackView.addArrangedSubview(messageContainerView)
messageStackView.addArrangedSubview(timestampLabel)
messageContainerView.addSubview(messageLabel)
stackView.addArrangedSubview(avatarImageView)
stackView.addArrangedSubview(messageStackView)
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
}
avatarImageView.snp.makeConstraints { make in
make.size.equalTo(32)
}
messageLabel.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(12)
}
messageStackView.snp.makeConstraints { make in
make.width.lessThanOrEqualTo(250)
}
}
// MARK: - Configuration
func configure(with message: ChatMessage) {
self.message = message
messageLabel.text = message.content
if let createdDate = message.createdDate {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
timestampLabel.text = formatter.string(from: createdDate)
} else {
timestampLabel.text = ""
}
switch message.role {
case .user:
configureUserMessage()
case .assistant:
configureAssistantMessage()
case .system:
configureSystemMessage()
}
}
private func configureUserMessage() {
// User message - align to right
stackView.semanticContentAttribute = .forceRightToLeft
messageContainerView.backgroundColor = .systemBlue
messageLabel.textColor = .white
avatarImageView.image = UIImage(systemName: "person.circle.fill")
avatarImageView.tintColor = .systemBlue
timestampLabel.textAlignment = .left
}
private func configureAssistantMessage() {
// Assistant message - align to left
stackView.semanticContentAttribute = .forceLeftToRight
messageContainerView.backgroundColor = .systemGray6
messageLabel.textColor = .label
avatarImageView.image = UIImage(systemName: "brain.head.profile")
avatarImageView.tintColor = .systemPurple
timestampLabel.textAlignment = .right
}
private func configureSystemMessage() {
// System message - center aligned
stackView.semanticContentAttribute = .forceLeftToRight
messageContainerView.backgroundColor = .systemYellow.withAlphaComponent(0.3)
messageLabel.textColor = .label
avatarImageView.image = UIImage(systemName: "gear")
avatarImageView.tintColor = .systemOrange
timestampLabel.textAlignment = .center
}
}

View File

@@ -0,0 +1,69 @@
//
// ChatCellFactory.swift
// Intelligents
//
// Created by on 6/27/25.
//
import UIKit
class ChatCellFactory {
// MARK: - Cell Registration
static func registerCells(for tableView: UITableView) {
tableView.register(UserMessageCell.self, forCellReuseIdentifier: CellType.userMessage.rawValue)
tableView.register(AssistantMessageCell.self, forCellReuseIdentifier: CellType.assistantMessage.rawValue)
tableView.register(SystemMessageCell.self, forCellReuseIdentifier: CellType.systemMessage.rawValue)
tableView.register(LoadingCell.self, forCellReuseIdentifier: CellType.loading.rawValue)
tableView.register(ErrorCell.self, forCellReuseIdentifier: CellType.error.rawValue)
}
// MARK: - Cell Creation
static func dequeueCell(
for tableView: UITableView,
at indexPath: IndexPath,
with viewModel: ChatCellViewModel
) -> ChatBaseCell {
let identifier = viewModel.cellType.rawValue
guard let cell = tableView.dequeueReusableCell(
withIdentifier: identifier,
for: indexPath
) as? ChatBaseCell else {
// cell使cellfallback
let fallbackCell = tableView.dequeueReusableCell(
withIdentifier: CellType.systemMessage.rawValue,
for: indexPath
) as! SystemMessageCell
// fallbackViewModel
let fallbackViewModel = SystemMessageCellViewModel(
id: viewModel.id,
content: "不支持的消息类型: \\(viewModel.cellType.rawValue)",
timestamp: Date()
)
fallbackCell.configure(with: fallbackViewModel)
return fallbackCell
}
cell.configure(with: viewModel)
return cell
}
// MARK: - Height Estimation
static func estimatedHeight(for viewModel: any ChatCellViewModel) -> CGFloat {
switch viewModel.cellType {
case .userMessage,
.assistantMessage:
80
case .systemMessage:
60
case .loading:
100
case .error:
120
}
}
}

View File

@@ -0,0 +1,97 @@
//
// ErrorCell.swift
// Intelligents
//
// Created by on 6/27/25.
//
import SnapKit
import Then
import UIKit
class ErrorCell: ChatBaseCell {
// MARK: - UI Components
private lazy var iconView = UIImageView().then {
$0.image = UIImage(systemName: "exclamationmark.triangle.fill")
$0.tintColor = .systemRed
$0.contentMode = .scaleAspectFit
}
private lazy var errorLabel = UILabel().then {
$0.numberOfLines = 0
$0.font = .systemFont(ofSize: 14, weight: .medium)
$0.textColor = .systemRed
}
private lazy var retryButton = UIButton(type: .system).then {
$0.setTitle("Retry", for: .normal)
$0.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
$0.setTitleColor(.systemBlue, for: .normal)
$0.backgroundColor = .systemBlue.withAlphaComponent(0.1)
$0.layer.cornerRadius = 8
$0.layer.cornerCurve = .continuous
$0.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
}
private lazy var contentStackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 12
$0.alignment = .top
}
private lazy var textStackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 12
$0.alignment = .fill
}
// MARK: - Properties
private var viewModel: ErrorCellViewModel?
// MARK: - Setup
override func setupContentView() {
containerView.addSubview(contentStackView)
contentStackView.addArrangedSubview(iconView)
contentStackView.addArrangedSubview(textStackView)
textStackView.addArrangedSubview(errorLabel)
textStackView.addArrangedSubview(retryButton)
contentStackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(contentInsets)
}
iconView.snp.makeConstraints { make in
make.width.height.equalTo(24)
}
retryButton.addTarget(self, action: #selector(retryButtonTapped), for: .touchUpInside)
}
// MARK: - Configuration
override func configure(with viewModel: any ChatCellViewModel) {
guard let errorViewModel = viewModel as? ErrorCellViewModel else {
assertionFailure()
return
}
self.viewModel = errorViewModel
errorLabel.text = errorViewModel.errorMessage
configureContainer(
backgroundColor: backgroundColor(for: errorViewModel.cellType),
borderColor: .systemRed.withAlphaComponent(0.3),
borderWidth: 1
)
}
// MARK: - Actions
@objc private func retryButtonTapped() {
// TODO:
}
}

View File

@@ -0,0 +1,89 @@
//
// LoadingCell.swift
// Intelligents
//
// Created by on 6/27/25.
//
import SnapKit
import Then
import UIKit
class LoadingCell: ChatBaseCell {
// MARK: - UI Components
private lazy var activityIndicator = UIActivityIndicatorView().then {
$0.style = .medium
$0.hidesWhenStopped = false
}
private lazy var messageLabel = UILabel().then {
$0.numberOfLines = 0
$0.font = .systemFont(ofSize: 14)
$0.textColor = .secondaryLabel
$0.textAlignment = .center
}
private lazy var progressView = UIProgressView().then {
$0.progressViewStyle = .default
$0.trackTintColor = .systemGray5
$0.progressTintColor = .systemBlue
}
private lazy var stackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 12
$0.alignment = .center
}
// MARK: - Properties
private var viewModel: LoadingCellViewModel?
// MARK: - Setup
override func setupContentView() {
containerView.addSubview(stackView)
stackView.addArrangedSubview(activityIndicator)
stackView.addArrangedSubview(messageLabel)
stackView.addArrangedSubview(progressView)
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(contentInsets)
}
progressView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.height.equalTo(4)
}
activityIndicator.startAnimating()
}
// MARK: - Configuration
override func configure(with viewModel: any ChatCellViewModel) {
guard let loadingViewModel = viewModel as? LoadingCellViewModel else { return }
self.viewModel = loadingViewModel
configureContainer(backgroundColor: backgroundColor(for: loadingViewModel.cellType))
//
if let message = loadingViewModel.message {
messageLabel.text = message
messageLabel.isHidden = false
} else {
messageLabel.text = "Processing..."
messageLabel.isHidden = false
}
//
if let progress = loadingViewModel.progress {
progressView.progress = Float(progress)
progressView.isHidden = false
} else {
progressView.isHidden = true
}
}
}

View File

@@ -0,0 +1,94 @@
//
// SystemMessageCell.swift
// Intelligents
//
// Created by on 6/27/25.
//
import SnapKit
import Then
import UIKit
class SystemMessageCell: ChatBaseCell {
// MARK: - UI Components
private lazy var iconView = UIImageView().then {
$0.image = UIImage(systemName: "info.circle.fill")
$0.tintColor = .systemOrange
$0.contentMode = .scaleAspectFit
}
private lazy var messageLabel = UILabel().then {
$0.numberOfLines = 0
$0.font = .systemFont(ofSize: 14, weight: .medium)
$0.textColor = .label
}
private lazy var timestampLabel = UILabel().then {
$0.font = .systemFont(ofSize: 12)
$0.textColor = .secondaryLabel
$0.textAlignment = .right
}
private lazy var contentStackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 12
$0.alignment = .top
}
private lazy var textStackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 4
$0.alignment = .fill
}
// MARK: - Properties
private var viewModel: SystemMessageCellViewModel?
// MARK: - Setup
override func setupContentView() {
containerView.addSubview(contentStackView)
contentStackView.addArrangedSubview(iconView)
contentStackView.addArrangedSubview(textStackView)
textStackView.addArrangedSubview(messageLabel)
textStackView.addArrangedSubview(timestampLabel)
contentStackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(contentInsets)
}
iconView.snp.makeConstraints { make in
make.width.height.equalTo(20)
}
}
// MARK: - Configuration
override func configure(with viewModel: any ChatCellViewModel) {
guard let systemViewModel = viewModel as? SystemMessageCellViewModel else { return }
self.viewModel = systemViewModel
messageLabel.text = systemViewModel.content
configureContainer(backgroundColor: backgroundColor(for: systemViewModel.cellType))
//
if let timestamp = systemViewModel.timestamp {
timestampLabel.text = formatTimestamp(timestamp)
timestampLabel.isHidden = false
} else {
timestampLabel.isHidden = true
}
}
// MARK: - Helpers
private func formatTimestamp(_ timestamp: Date) -> String {
let formatter = DateFormatter()
formatter.timeStyle = .short
return formatter.string(from: timestamp)
}
}

View File

@@ -0,0 +1,94 @@
//
// UserMessageCell.swift
// Intelligents
//
// Created by on 6/27/25.
//
import SnapKit
import Then
import UIKit
class UserMessageCell: ChatBaseCell {
// MARK: - UI Components
private lazy var messageLabel = UILabel().then {
$0.numberOfLines = 0
$0.font = .systemFont(ofSize: 16)
$0.textColor = .label
}
private lazy var timestampLabel = UILabel().then {
$0.font = .systemFont(ofSize: 12)
$0.textColor = .secondaryLabel
$0.textAlignment = .right
}
private lazy var retryIndicator = UIActivityIndicatorView().then {
$0.style = .medium
$0.hidesWhenStopped = true
}
private lazy var stackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 8
$0.alignment = .fill
}
// MARK: - Properties
private var viewModel: UserMessageCellViewModel?
// MARK: - Setup
override func setupContentView() {
containerView.addSubview(stackView)
stackView.addArrangedSubview(messageLabel)
let bottomContainer = UIView()
stackView.addArrangedSubview(bottomContainer)
bottomContainer.addSubview(retryIndicator)
bottomContainer.addSubview(timestampLabel)
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(contentInsets)
}
retryIndicator.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
make.width.height.equalTo(16)
}
timestampLabel.snp.makeConstraints { make in
make.trailing.top.bottom.equalToSuperview()
make.leading.greaterThanOrEqualTo(retryIndicator.snp.trailing).offset(8)
}
bottomContainer.snp.makeConstraints { make in
make.height.equalTo(16)
}
}
// MARK: - Configuration
override func configure(with viewModel: any ChatCellViewModel) {
guard let userViewModel = viewModel as? UserMessageCellViewModel else { return }
self.viewModel = userViewModel
messageLabel.text = userViewModel.content
configureContainer(backgroundColor: backgroundColor(for: userViewModel.cellType))
let timestamp = userViewModel.timestamp
timestampLabel.text = formatTimestamp(timestamp)
timestampLabel.isHidden = false
}
// MARK: - Helpers
private func formatTimestamp(_ timestamp: Date) -> String {
let formatter = DateFormatter()
formatter.timeStyle = .short
return formatter.string(from: timestamp)
}
}

View File

@@ -0,0 +1,44 @@
//
// AssistantMessageCellViewModel.swift
// Intelligents
//
// Created by on 6/27/25.
//
import Foundation
struct AssistantMessageCellViewModel: ChatCellViewModel {
var cellType: CellType = .assistantMessage
var id: UUID
var content: String
var timestamp: Date
var isStreaming: Bool = false
var model: String?
var tokens: Int?
var canRetry: Bool = false
var citations: [CitationViewModel]?
var actions: [MessageActionViewModel]?
}
struct CitationViewModel: Codable, Identifiable, Equatable, Hashable {
var id: String
var title: String
var url: String?
var snippet: String?
}
struct MessageActionViewModel: Codable, Identifiable, Equatable, Hashable {
var id: String
var title: String
var actionType: ActionType
var data: [String: String]?
enum ActionType: String, Codable {
case copy
case regenerate
case like
case dislike
case share
case edit
}
}

View File

@@ -1,23 +0,0 @@
//
// AttachmentCellViewModel.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
struct AttachmentCellViewModel: ChatCellViewModel {
var cellType: CellType = .attachment
var id: String
var attachments: [AttachmentViewModel]
var parentMessageId: String
}
struct AttachmentViewModel: Codable, Identifiable, Equatable, Hashable {
var id: String
var url: String
var mimeType: String?
var fileName: String?
var size: Int64?
}

View File

@@ -7,14 +7,10 @@
import Foundation
enum CellType: String, Codable, CaseIterable {
public enum CellType: String, Codable, CaseIterable {
case userMessage
case assistantMessage
case systemMessage
case attachment
case contextReference
case workflowStatus
case transcription
case loading
case error
}

View File

@@ -0,0 +1,13 @@
//
// ChatCellViewModel.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
public protocol ChatCellViewModel: Codable, Identifiable, Equatable, Hashable {
var id: UUID { get }
var cellType: CellType { get }
}

View File

@@ -1,41 +0,0 @@
//
// ChatCellViewModels.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
protocol ChatCellViewModel: Codable, Identifiable, Equatable, Hashable {
var cellType: CellType { get }
var id: String { get }
}
struct UserMessageCellViewModel: ChatCellViewModel {
var cellType: CellType = .userMessage
var id: String
var content: String
var attachments: [AttachmentViewModel]
var timestamp: Date?
var isRetrying: Bool
}
struct AssistantMessageCellViewModel: ChatCellViewModel {
var cellType: CellType = .assistantMessage
var id: String
var content: String
var attachments: [AttachmentViewModel]
var timestamp: Date?
var isStreaming: Bool
var model: String?
var tokens: Int?
var canRetry: Bool
}
struct SystemMessageCellViewModel: ChatCellViewModel {
var cellType: CellType = .systemMessage
var id: String
var content: String
var timestamp: Date?
}

View File

@@ -1,15 +0,0 @@
//
// ContextReferenceCellViewModel.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
struct ContextReferenceCellViewModel: ChatCellViewModel {
var cellType: CellType = .contextReference
var id: String
var references: [ChatManager.ContextReference]
var parentMessageId: String
}

View File

@@ -9,8 +9,6 @@ import Foundation
struct ErrorCellViewModel: ChatCellViewModel {
var cellType: CellType = .error
var id: String
var id: UUID
var errorMessage: String
var canRetry: Bool
var retryAction: String?
}

View File

@@ -9,7 +9,7 @@ import Foundation
struct LoadingCellViewModel: ChatCellViewModel {
var cellType: CellType = .loading
var id: String
var id: UUID
var message: String?
var progress: Double?
}

View File

@@ -0,0 +1,15 @@
//
// SystemMessageCellViewModel.swift
// Intelligents
//
// Created by on 6/27/25.
//
import Foundation
struct SystemMessageCellViewModel: ChatCellViewModel {
var cellType: CellType = .systemMessage
var id: UUID
var content: String
var timestamp: Date?
}

View File

@@ -0,0 +1,16 @@
//
// UserMessageCellViewModel.swift
// Intelligents
//
// Created by on 6/27/25.
//
import Foundation
struct UserMessageCellViewModel: ChatCellViewModel {
var cellType: CellType = .userMessage
var id: UUID
var content: String
var timestamp: Date
var attachments: [String] = []
}

View File

@@ -1,15 +0,0 @@
//
// WorkflowStatusCellViewModel.swift
// Intelligents
//
// Created by on 6/26/25.
//
import Foundation
struct WorkflowStatusCellViewModel: ChatCellViewModel {
var cellType: CellType = .workflowStatus
var id: String
var workflow: ChatManager.WorkflowEventData
var parentMessageId: String
}

View File

@@ -0,0 +1,154 @@
import Combine
import OrderedCollections
import SnapKit
import Then
import UIKit
protocol ChatTableViewDelegate: AnyObject {
func chatTableView(_ tableView: ChatTableView, didSelectRowAt indexPath: IndexPath)
}
class ChatTableView: UIView {
// MARK: - UI Components
lazy var tableView = UITableView().then {
$0.backgroundColor = .clear
$0.separatorStyle = .none
$0.delegate = self
$0.dataSource = self
$0.keyboardDismissMode = .interactive
$0.contentInsetAdjustmentBehavior = .never
$0.tableFooterView = UIView(frame: .init(x: 0, y: 0, width: 100, height: 500))
}
lazy var emptyStateView = UIView().then {
$0.isHidden = true
}
lazy var emptyStateLabel = UILabel().then {
$0.text = "Start a conversation..."
$0.font = .systemFont(ofSize: 18, weight: .medium)
$0.textColor = .systemGray
$0.textAlignment = .center
}
// MARK: - Properties
weak var delegate: ChatTableViewDelegate?
var sessionId: String? {
didSet {
if let sessionId {
bindToSession(sessionId)
}
}
}
private var cancellables = Set<AnyCancellable>()
var cellViewModels: OrderedDictionary<UUID, any ChatCellViewModel> = [:] {
didSet {
updateEmptyState()
tableView.reloadData()
if !cellViewModels.isEmpty {
let indexPath = IndexPath(row: cellViewModels.count - 1, section: 0)
tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
}
}
}
// MARK: - Initialization
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupUI()
}
// MARK: - Setup
private func setupUI() {
// cell
ChatCellFactory.registerCells(for: tableView)
addSubview(tableView)
addSubview(emptyStateView)
emptyStateView.addSubview(emptyStateLabel)
tableView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
emptyStateView.snp.makeConstraints { make in
make.center.equalTo(tableView)
make.width.lessThanOrEqualTo(tableView).inset(32)
}
emptyStateLabel.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
// MARK: - Public Methods
func scrollToBottom(animated: Bool = true) {
guard !cellViewModels.isEmpty else { return }
let indexPath = IndexPath(row: cellViewModels.count - 1, section: 0)
tableView.scrollToRow(at: indexPath, at: .bottom, animated: animated)
}
// MARK: - Private Methods
private func bindToSession(_ sessionId: String) {
cancellables.removeAll()
ChatManager.shared.$viewModels
.map { $0[sessionId] ?? [:] }
.receive(on: DispatchQueue.main)
.sink { [weak self] viewModels in
self?.cellViewModels = viewModels
}
.store(in: &cancellables)
}
private func updateEmptyState() {
emptyStateView.isHidden = !cellViewModels.isEmpty
tableView.isHidden = cellViewModels.isEmpty
}
}
// MARK: - UITableViewDataSource
extension ChatTableView: UITableViewDataSource {
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
cellViewModels.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let viewModel = cellViewModels.elements[indexPath.row].value
return ChatCellFactory.dequeueCell(for: tableView, at: indexPath, with: viewModel)
}
}
// MARK: - UITableViewDelegate
extension ChatTableView: UITableViewDelegate {
func tableView(_: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
let viewModel = cellViewModels.elements[indexPath.row].value
return ChatCellFactory.estimatedHeight(for: viewModel)
}
func tableView(_: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
let viewModel = cellViewModels.elements[indexPath.row].value
return ChatCellFactory.estimatedHeight(for: viewModel)
}
func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) {
delegate?.chatTableView(self, didSelectRowAt: indexPath)
}
}

View File

@@ -85,4 +85,20 @@ extension InputBox: UITextViewDelegate {
updatePlaceholderVisibility()
updateTextViewHeight()
}
func textView(_: UITextView, shouldChangeTextIn _: NSRange, replacementText text: String) -> Bool {
if text == "\n" {
delegate?.inputBoxDidSend(self)
return false
}
return true
}
func textView(_ textView: UITextView, editMenuForTextIn _: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
let insertNewLineAction = UIAction(title: "Insert New Line") { _ in
textView.insertText("\n")
}
return UIMenu(children: suggestedActions + [insertNewLineAction])
}
}

View File

@@ -30,6 +30,7 @@ class InputBox: UIView {
$0.textContainerInset = .zero
$0.delegate = self
$0.text = ""
$0.returnKeyType = .send
}
lazy var placeholderLabel = UILabel().then {
@@ -147,7 +148,7 @@ class InputBox: UIView {
}
.store(in: &cancellables)
viewModel.$isNetworkEnabled
viewModel.$isSearchEnabled
.removeDuplicates()
.sink { [weak self] enabled in
self?.functionBar.updateNetworkState(isEnabled: enabled)

View File

@@ -5,6 +5,16 @@ import UIKit
private let unselectedColor: UIColor = .affineIconPrimary
private let selectedColor: UIColor = .affineIconActivated
private let configurableOptions: [ConfigurableOptions] = [
.networking,
.reasoning,
]
enum ConfigurableOptions {
case tool
case networking
case reasoning
}
class InputBoxFunctionBar: UIView {
weak var delegate: InputBoxFunctionBarDelegate?
@@ -23,6 +33,7 @@ class InputBoxFunctionBar: UIView {
$0.tintColor = unselectedColor
$0.imageView?.contentMode = .scaleAspectFit
$0.addTarget(self, action: #selector(toolButtonTapped), for: .touchUpInside)
$0.isHidden = !configurableOptions.contains(.tool)
}
lazy var networkButton = UIButton(type: .system).then {
@@ -30,6 +41,7 @@ class InputBoxFunctionBar: UIView {
$0.tintColor = unselectedColor
$0.imageView?.contentMode = .scaleAspectFit
$0.addTarget(self, action: #selector(networkButtonTapped), for: .touchUpInside)
$0.isHidden = !configurableOptions.contains(.networking)
}
lazy var deepThinkingButton = UIButton(type: .system).then {
@@ -37,6 +49,7 @@ class InputBoxFunctionBar: UIView {
$0.tintColor = unselectedColor
$0.imageView?.contentMode = .scaleAspectFit
$0.addTarget(self, action: #selector(deepThinkingButtonTapped), for: .touchUpInside)
$0.isHidden = !configurableOptions.contains(.reasoning)
}
lazy var sendButton = UIButton(type: .system).then {

View File

@@ -16,16 +16,16 @@ public struct InputBoxData {
public var fileAttachments: [FileAttachment] = []
public var documentAttachments: [DocumentAttachment] = []
public var isToolEnabled: Bool
public var isNetworkEnabled: Bool
public var isSearchEnabled: Bool
public var isDeepThinkingEnabled: Bool
public init(text: String, imageAttachments: [ImageAttachment], fileAttachments: [FileAttachment], documentAttachments: [DocumentAttachment], isToolEnabled: Bool, isNetworkEnabled: Bool, isDeepThinkingEnabled: Bool) {
public init(text: String, imageAttachments: [ImageAttachment], fileAttachments: [FileAttachment], documentAttachments: [DocumentAttachment], isToolEnabled: Bool, isSearchEnabled: Bool, isDeepThinkingEnabled: Bool) {
self.text = text
self.imageAttachments = imageAttachments
self.fileAttachments = fileAttachments
self.documentAttachments = documentAttachments
self.isToolEnabled = isToolEnabled
self.isNetworkEnabled = isNetworkEnabled
self.isSearchEnabled = isSearchEnabled
self.isDeepThinkingEnabled = isDeepThinkingEnabled
}
}
@@ -37,7 +37,7 @@ public class InputBoxViewModel: ObservableObject {
@Published public var inputText: String = ""
@Published public var isToolEnabled: Bool = false
@Published public var isNetworkEnabled: Bool = false
@Published public var isSearchEnabled: Bool = false
@Published public var isDeepThinkingEnabled: Bool = false
@Published public var imageAttachments: [ImageAttachment] = []
@Published public var fileAttachments: [FileAttachment] = []
@@ -90,7 +90,7 @@ public extension InputBoxViewModel {
}
func toggleNetwork() {
isNetworkEnabled.toggle()
isSearchEnabled.toggle()
}
func toggleDeepThinking() {
@@ -148,7 +148,7 @@ public extension InputBoxViewModel {
fileAttachments: fileAttachments,
documentAttachments: documentAttachments,
isToolEnabled: isToolEnabled,
isNetworkEnabled: isNetworkEnabled,
isSearchEnabled: isSearchEnabled,
isDeepThinkingEnabled: isDeepThinkingEnabled
)
}
@@ -159,7 +159,7 @@ public extension InputBoxViewModel {
fileAttachments.removeAll()
documentAttachments.removeAll()
isToolEnabled = false
isNetworkEnabled = false
isSearchEnabled = false
isDeepThinkingEnabled = false
}
}

View File

@@ -1,5 +1,6 @@
import './chat-panel-messages';
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { ContextEmbedStatus, CopilotSessionType } from '@affine/graphql';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
@@ -241,6 +242,9 @@ export class ChatPanel extends SignalWatcher(
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@property({ attribute: false })
accessor affineWorkspaceDialogService!: WorkspaceDialogService;
@state()
accessor isLoading = false;
@@ -481,6 +485,7 @@ export class ChatPanel extends SignalWatcher(
.playgroundConfig=${this.playgroundConfig}
.docDisplayConfig=${this.docDisplayConfig}
.searchMenuConfig=${this.searchMenuConfig}
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
.trackOptions=${{
where: 'chat-panel',
control: 'chat-send',

View File

@@ -1,5 +1,6 @@
import './ai-chat-composer-tip';
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type {
ContextEmbedStatus,
ContextWorkspaceEmbeddingStatus,
@@ -104,11 +105,11 @@ export class AIChatComposer extends SignalWatcher(
@property({ attribute: false })
accessor panelWidth: Signal<number | undefined> = signal(undefined);
@state()
accessor chips: ChatChip[] = [];
@property({ attribute: false })
accessor affineWorkspaceDialogService!: WorkspaceDialogService;
@state()
accessor embeddingProgressText = 'Loading embedding status...';
accessor chips: ChatChip[] = [];
@state()
accessor embeddingCompleted = false;
@@ -160,7 +161,8 @@ export class AIChatComposer extends SignalWatcher(
this.embeddingCompleted
? null
: html`<ai-chat-embedding-status-tooltip
.progressText=${this.embeddingProgressText}
.affineWorkspaceDialogService=${this
.affineWorkspaceDialogService}
/>`,
].filter(Boolean)}
.loop=${false}
@@ -348,24 +350,20 @@ export class AIChatComposer extends SignalWatcher(
this.host.std.workspace.id,
(status: ContextWorkspaceEmbeddingStatus) => {
if (!status) {
this.embeddingProgressText = 'Loading embedding status...';
this.embeddingCompleted = false;
return;
}
const completed = status.embedded === status.total;
this.embeddingCompleted = completed;
if (completed) {
this.embeddingProgressText =
'Embedding finished. You are getting the best results!';
this.embeddingCompleted = true;
} else {
this.embeddingProgressText =
'File not embedded yet. Results will improve after embedding.';
this.embeddingCompleted = false;
}
},
signal
);
} catch {
this.embeddingProgressText = 'Failed to load embedding status...';
this.embeddingCompleted = false;
}
};

View File

@@ -50,7 +50,8 @@ export class AIChatInput extends SignalWatcher(
0px 0px 0px 0px rgba(28, 158, 228, 0),
0px 0px 0px 2px transparent;
}
[data-theme='light'] .chat-panel-input {
[data-theme='light'] .chat-panel-input,
.chat-panel-input {
box-shadow:
var(--border-shadow),
0px 0px 0px 3px transparent,
@@ -252,6 +253,9 @@ export class AIChatInput extends SignalWatcher(
font-size: 20px;
background: var(--affine-v2-icon-activated);
color: var(--affine-v2-layer-pureWhite);
border: none;
padding: 0;
cursor: pointer;
}
.chat-panel-send[aria-disabled='true'] {
cursor: not-allowed;
@@ -268,6 +272,9 @@ export class AIChatInput extends SignalWatcher(
border-radius: 50%;
font-size: 24px;
color: var(--affine-v2-icon-activated);
border: none;
padding: 0;
background: transparent;
}
.chat-input-footer-spacer {
flex: 1;

View File

@@ -1,7 +1,9 @@
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { SignalWatcher } from '@blocksuite/affine/global/lit';
import { unsafeCSSVar } from '@blocksuite/affine/shared/theme';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { debounce } from 'lodash-es';
export class AIChatEmbeddingStatusTooltip extends SignalWatcher(LitElement) {
static override styles = css`
@@ -34,7 +36,21 @@ export class AIChatEmbeddingStatusTooltip extends SignalWatcher(LitElement) {
`;
@property({ attribute: false })
accessor progressText = 'Loading embedding status...';
accessor affineWorkspaceDialogService!: WorkspaceDialogService;
override connectedCallback() {
super.connectedCallback();
}
private readonly _handleCheckStatusClick = debounce(
() => {
this.affineWorkspaceDialogService.open('setting', {
activeTab: 'workspace:embedding',
});
},
1000,
{ leading: true }
);
override render() {
return html`
@@ -48,11 +64,9 @@ export class AIChatEmbeddingStatusTooltip extends SignalWatcher(LitElement) {
<div
class="check-status"
data-testid="ai-chat-embedding-status-tooltip-check"
@click=${this._handleCheckStatusClick}
>
Check status
<affine-tooltip tip-position="top-start"
>${this.progressText}</affine-tooltip
>
</div>
</div>
`;

View File

@@ -26,6 +26,9 @@ export class ChatInputPreference extends SignalWatcher(
color: var(--affine-v2-icon-primary);
transition: all 0.23s ease;
border-radius: 4px;
background: transparent;
border: none;
cursor: pointer;
}
.chat-input-preference-trigger:hover {
background-color: var(--affine-v2-layer-background-hoverOverlay);

View File

@@ -1,8 +1,9 @@
import { scrollbarStyle } from '@blocksuite/affine/shared/styles';
import { CloseIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
export class ImagePreviewGrid extends LitElement {
static override styles = css`
@@ -11,6 +12,10 @@ export class ImagePreviewGrid extends LitElement {
overflow-y: hidden;
max-height: 80px;
white-space: nowrap;
/* to prevent the close button from being clipped */
padding-top: 8px;
margin-top: -8px;
}
${scrollbarStyle('.image-preview-wrapper')}
@@ -28,42 +33,42 @@ export class ImagePreviewGrid extends LitElement {
height: 68px;
border-radius: 4px;
cursor: pointer;
overflow: hidden;
position: relative;
display: flex;
justify-content: center;
align-items: center;
flex: 0 0 auto;
}
.image-container img {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
border: 1px solid var(--affine-v2-layer-insideBorder-border);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.close-wrapper {
width: 16px;
height: 16px;
border-radius: 4px;
border: 1px solid var(--affine-border-color);
border: 0.5px solid var(--affine-v2-layer-insideBorder-border);
justify-content: center;
align-items: center;
display: none;
position: absolute;
background-color: var(--affine-white);
background-color: var(--affine-v2-layer-background-primary);
color: var(--affine-v2-icon-primary);
z-index: 1;
cursor: pointer;
top: -6px;
right: -6px;
}
.image-container:hover .close-wrapper {
display: flex;
}
.close-wrapper:hover {
background-color: var(--affine-background-error-color);
border: 1px solid var(--affine-error-color);
}
.close-wrapper:hover svg path {
fill: var(--affine-error-color);
background-color: var(--affine-v2-layer-background-error);
border: 0.5px solid var(--affine-v2-button-error);
color: var(--affine-v2-button-error);
}
`;
@@ -129,37 +134,11 @@ export class ImagePreviewGrid extends LitElement {
}
};
private readonly _handleMouseEnter = (evt: MouseEvent, index: number) => {
const ele = evt.target as HTMLImageElement;
const rect = ele.getBoundingClientRect();
if (!ele.parentElement) return;
const parentRect = ele.parentElement.getBoundingClientRect();
const left = Math.abs(rect.right - parentRect.left);
const top = Math.abs(parentRect.top - rect.top);
this.currentIndex = index;
if (!this.closeWrapper) return;
this.closeWrapper.style.display = 'flex';
this.closeWrapper.style.left = left + 'px';
this.closeWrapper.style.top = top + 'px';
};
private readonly _handleMouseLeave = () => {
if (!this.closeWrapper) return;
this.closeWrapper.style.display = 'none';
this.currentIndex = -1;
};
private readonly _handleDelete = () => {
if (this.currentIndex >= 0 && this.currentIndex < this.images.length) {
const file = this.images[this.currentIndex];
const url = this._getObjectUrl(file);
this._releaseObjectUrl(url);
this.onImageRemove?.(this.currentIndex);
this.currentIndex = -1;
if (!this.closeWrapper) return;
this.closeWrapper.style.display = 'none';
}
private readonly _handleDelete = (index: number) => {
const file = this.images[index];
const url = this._getObjectUrl(file);
this._releaseObjectUrl(url);
this.onImageRemove?.(index);
};
override disconnectedCallback() {
@@ -169,7 +148,7 @@ export class ImagePreviewGrid extends LitElement {
override render() {
return html`
<div class="image-preview-wrapper" @mouseleave=${this._handleMouseLeave}>
<div class="image-preview-wrapper">
<div class="images-container">
${repeat(
this.images,
@@ -180,18 +159,21 @@ export class ImagePreviewGrid extends LitElement {
<div
class="image-container"
@error=${() => this._releaseObjectUrl(url)}
@mouseenter=${(evt: MouseEvent) =>
this._handleMouseEnter(evt, index)}
style=${styleMap({
backgroundImage: `url(${url})`,
})}
>
<img src="${url}" alt="${image.name}" />
<div
class="close-wrapper"
@click=${() => this._handleDelete(index)}
>
${CloseIcon()}
</div>
</div>
`;
}
)}
</div>
<div class="close-wrapper" @click=${this._handleDelete}>
${CloseIcon()}
</div>
</div>
`;
}
@@ -201,12 +183,6 @@ export class ImagePreviewGrid extends LitElement {
@property({ attribute: false })
accessor onImageRemove: ((index: number) => void) | null = null;
@query('.close-wrapper')
accessor closeWrapper: HTMLDivElement | null = null;
@state()
accessor currentIndex = -1;
}
declare global {

View File

@@ -1,3 +1,4 @@
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { ContextEmbedStatus } from '@affine/graphql';
import {
@@ -593,6 +594,7 @@ export class AIChatBlockPeekView extends LitElement {
.networkSearchConfig=${networkSearchConfig}
.docDisplayConfig=${this.docDisplayConfig}
.searchMenuConfig=${this.searchMenuConfig}
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
.onChatSuccess=${this._onChatSuccess}
.trackOptions=${{
where: 'ai-chat-block',
@@ -628,6 +630,9 @@ export class AIChatBlockPeekView extends LitElement {
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@property({ attribute: false })
accessor affineWorkspaceDialogService!: WorkspaceDialogService;
@state()
accessor _historyMessages: ChatMessage[] = [];
@@ -657,7 +662,8 @@ export const AIChatBlockPeekViewTemplate = (
searchMenuConfig: SearchMenuConfig,
networkSearchConfig: AINetworkSearchConfig,
reasoningConfig: AIReasoningConfig,
affineFeatureFlagService: FeatureFlagService
affineFeatureFlagService: FeatureFlagService,
affineWorkspaceDialogService: WorkspaceDialogService
) => {
return html`<ai-chat-block-peek-view
.blockModel=${blockModel}
@@ -667,5 +673,6 @@ export const AIChatBlockPeekViewTemplate = (
.searchMenuConfig=${searchMenuConfig}
.reasoningConfig=${reasoningConfig}
.affineFeatureFlagService=${affineFeatureFlagService}
.affineWorkspaceDialogService=${affineWorkspaceDialogService}
></ai-chat-block-peek-view>`;
};

View File

@@ -110,7 +110,10 @@ interface OauthProviderProps {
}
function OAuthProvider({ onContinue, provider }: OauthProviderProps) {
const { icon } = OAuthProviderMap[provider];
const { icon } =
provider in OAuthProviderMap
? OAuthProviderMap[provider]
: { icon: undefined };
const onClick = useCallback(() => {
onContinue(provider);

View File

@@ -1,6 +1,7 @@
import { ChatPanel } from '@affine/core/blocksuite/ai';
import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor';
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { ViewExtensionManagerIdentifier } from '@blocksuite/affine/ext-loader';
@@ -80,6 +81,9 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
.get('preview-page');
chatPanelRef.current.affineFeatureFlagService =
framework.get(FeatureFlagService);
chatPanelRef.current.affineWorkspaceDialogService = framework.get(
WorkspaceDialogService
);
containerRef.current?.append(chatPanelRef.current);
} else {

View File

@@ -41,9 +41,12 @@ export class FetchService extends Service {
});
const timeout = init?.timeout ?? 15000;
const timeoutId = setTimeout(() => {
abortController.abort('timeout');
}, timeout);
const timeoutId =
timeout > 0
? setTimeout(() => {
abortController.abort(new Error('timeout after ' + timeout + 'ms'));
}, timeout)
: undefined;
let res: Response;

View File

@@ -43,7 +43,7 @@ export class AudioTranscriptionJobStore extends Entity<{
}
const files = await this.props.getAudioFiles();
const response = await graphqlService.gql({
timeout: 600_000, // default 15s is too short for audio transcription
timeout: 0, // default 15s is too short for audio transcription
query: submitAudioTranscriptionMutation,
variables: {
workspaceId: this.currentWorkspaceId,

View File

@@ -2,6 +2,7 @@ import { toReactNode } from '@affine/component';
import { AIChatBlockPeekViewTemplate } from '@affine/core/blocksuite/ai';
import type { AIChatBlockModel } from '@affine/core/blocksuite/ai/blocks/ai-chat-block/model/ai-chat-model';
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { EditorHost } from '@blocksuite/affine/std';
import { useFramework } from '@toeverything/infra';
@@ -25,6 +26,7 @@ export const AIChatBlockPeekView = ({
const framework = useFramework();
const affineFeatureFlagService = framework.get(FeatureFlagService);
const affineWorkspaceDialogService = framework.get(WorkspaceDialogService);
return useMemo(() => {
const template = AIChatBlockPeekViewTemplate(
@@ -34,7 +36,8 @@ export const AIChatBlockPeekView = ({
searchMenuConfig,
networkSearchConfig,
reasoningConfig,
affineFeatureFlagService
affineFeatureFlagService,
affineWorkspaceDialogService
);
return toReactNode(template);
}, [
@@ -45,5 +48,6 @@ export const AIChatBlockPeekView = ({
networkSearchConfig,
reasoningConfig,
affineFeatureFlagService,
affineWorkspaceDialogService,
]);
};

View File

@@ -17,7 +17,7 @@ test.describe('AIBasic/Chat', () => {
await expect(page.getByTestId('ai-onboarding')).toBeVisible();
});
test('should display embedding status tooltip', async ({
test('should open embedding settings when clicking check status button', async ({
loggedInPage: page,
utils,
}) => {
@@ -32,12 +32,8 @@ test.describe('AIBasic/Chat', () => {
);
await expect(check).toBeVisible({ timeout: 50 * 1000 });
await check.hover();
const tooltip = await page.getByTestId('ai-chat-embedding-status-tooltip');
await expect(tooltip).toBeVisible();
await expect(tooltip).toHaveText(
/Results will improve after embedding|Embedding finished/
);
await check.click();
await expect(page.getByTestId('workspace-setting:embedding')).toBeVisible();
});
test(`should send message and receive AI response:

View File

@@ -261,7 +261,7 @@ export class ChatPanelUtils {
) {
await this.uploadImages(page, images);
await page.waitForSelector('ai-chat-input img');
await page.waitForSelector('ai-chat-input .image-container');
await this.makeChat(page, text);
}